1import * as registry from "@distilled.cloud/gcp/agentregistry_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { alchemyLabelKeys, hasAlchemyLabels } from "../Labels.ts";
8import { isTransientGcpError } from "../Errors.ts";
9import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
10
11export const MAX_ID_LENGTH = 63;
12export const MAX_DESCRIPTION_LENGTH = 2048;
13export const MAX_DISPLAY_NAME_LENGTH = 63;
14
15export class ResourceNotResolved extends Data.TaggedError(
16 "GCP.AgentRegistry.NotResolved",
17)<{
18 name: string;
19}> {}
20
21export class ResourcePending extends Data.TaggedError(
22 "GCP.AgentRegistry.Pending",
23)<{
24 name: string;
25}> {}
26
27export const lastSegment = (value: string) => {
28 const trimmed = value.replace(/\/+$/, "");
29 const parts = trimmed.split("/");
30 return parts[parts.length - 1] || trimmed;
31};
32
33export const rfc1035 = (name: string, fallback = "bind"): string => {
34 let next = name
35 .toLowerCase()
36 .replace(/[^a-z0-9-]/g, "-")
37 .replace(/-+/g, "-")
38 .replace(/^-+|-+$/g, "");
39 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "b"}${next}`;
40 next = next.slice(0, MAX_ID_LENGTH).replace(/-+$/g, "");
41 if (next.length === 0) return fallback;
42 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_ID_LENGTH - 1)}0`;
43 if (next.length < 4) next = `${next}xxxx`.slice(0, 4);
44 return next.slice(0, MAX_ID_LENGTH);
45};
46
47export const normalizeLocation = (
48 location: string | undefined,
49 defaultLocation: string,
50) => lastSegment(location ?? defaultLocation).toLowerCase();
51
52export const locationParent = (project: string, location: string) =>
53 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
54
55export const resourceName = (
56 project: string,
57 location: string,
58 collection: string,
59 id: string,
60) => `${locationParent(project, location)}/${collection}/${id}`;
61
62export const parseResourceName = (name: string, collection: string) => {
63 const parts = name.split("/").filter((part) => part.length > 0);
64 const collectionAt = parts.lastIndexOf(collection);
65 const locationsAt = parts.lastIndexOf("locations");
66 const projectsAt = parts.lastIndexOf("projects");
67 return {
68 project:
69 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
70 location:
71 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
72 id:
73 collectionAt >= 0 && parts[collectionAt + 1]
74 ? parts[collectionAt + 1]!
75 : lastSegment(name),
76 parent:
77 collectionAt > 0
78 ? parts.slice(0, collectionAt).join("/")
79 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
80 };
81};
82
83export const toPhysicalId = (
84 id: string,
85 explicit: string | undefined,
86 existing: string | undefined,
87 fallback = "bind",
88) =>
89 Effect.gen(function* () {
90 if (explicit !== undefined) return rfc1035(explicit, fallback);
91 if (existing !== undefined) return existing;
92 return rfc1035(
93 yield* createPhysicalName({
94 id,
95 maxLength: MAX_ID_LENGTH,
96 lowercase: true,
97 }),
98 fallback,
99 );
100 });
101
102export const sameText = (left: string | undefined, right: string | undefined) =>
103 (left ?? "") === (right ?? "");
104
105export const canonical = (value: unknown): unknown => {
106 if (value === undefined || value === null) return undefined;
107 if (typeof value === "boolean" || typeof value === "number") return value;
108 if (typeof value === "string") {
109 return value.length === 0 ? undefined : value;
110 }
111 if (Array.isArray(value)) {
112 const items = value.map(canonical).filter((item) => item !== undefined);
113 return items.length === 0 ? undefined : items;
114 }
115 if (typeof value === "object") {
116 const entries = Object.entries(value as Record<string, unknown>)
117 .map(([key, item]) => [key, canonical(item)] as const)
118 .filter(([, item]) => item !== undefined)
119 .sort(([left], [right]) => left.localeCompare(right));
120 if (entries.length === 0) return undefined;
121 return Object.fromEntries(entries);
122 }
123 return undefined;
124};
125
126export const fingerprint = (value: unknown): string =>
127 JSON.stringify(canonical(value) ?? null);
128
129export const sameJson = (left: unknown, right: unknown) =>
130 fingerprint(left) === fingerprint(right);
131
132export const updateMaskOf = (...fields: Array<string | undefined>) =>
133 fields.filter((field): field is string => field !== undefined).join(",");
134
135export const replaceOnIdentity = (input: {
136 previousId?: string;
137 nextId?: string;
138 previousLocation?: string;
139 nextLocation?: string;
140 extra?: boolean;
141}) => {
142 const idChanged =
143 input.previousId !== undefined &&
144 input.nextId !== undefined &&
145 input.previousId !== input.nextId;
146 const locationChanged =
147 (input.previousLocation ?? "") !== "" &&
148 (input.nextLocation ?? "") !== "" &&
149 (input.previousLocation ?? "") !== (input.nextLocation ?? "");
150 if (!idChanged && !locationChanged && input.extra !== true) {
151 return undefined;
152 }
153 const samePhysical =
154 !locationChanged &&
155 input.previousId !== undefined &&
156 input.nextId === input.previousId;
157 return {
158 action: "replace" as const,
159 deleteFirst: samePhysical,
160 };
161};
162
163const markerOf = (stack: string, stage: string, id: string) =>
164 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
165
166export const encodeOwnership = (
167 labels: Record<string, string>,
168 description: string | undefined,
169): string => {
170 const marker = markerOf(
171 labels[alchemyLabelKeys.stack] ?? "x",
172 labels[alchemyLabelKeys.stage] ?? "x",
173 labels[alchemyLabelKeys.id] ?? "x",
174 ).slice(0, MAX_DESCRIPTION_LENGTH);
175 const trimmed = description?.trim();
176 if (!trimmed) return marker;
177 return `${marker}\n${trimmed}`.slice(0, MAX_DESCRIPTION_LENGTH);
178};
179
180export const parseOwnership = (
181 description: string | undefined,
182): {
183 labels: Record<string, string>;
184 description: string | undefined;
185} => {
186 if (!description?.startsWith("[alchemy ")) {
187 return { labels: {}, description };
188 }
189 const end = description.indexOf("]");
190 if (end < 0) return { labels: {}, description };
191 const labels: Record<string, string> = {};
192 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
193 const eq = part.indexOf("=");
194 if (eq > 0) {
195 labels[part.slice(0, eq)] = part.slice(eq + 1);
196 }
197 }
198 const rest = description.slice(end + 1).replace(/^\n/, "");
199 return { labels, description: rest.length > 0 ? rest : undefined };
200};
201
202export const hasOwnershipMarker = (description: string | undefined) =>
203 Object.keys(parseOwnership(description).labels).some((key) =>
204 key.startsWith("alchemy-"),
205 );
206
207export const ownedByAlchemy = (id: string, description: string | undefined) =>
208 Effect.gen(function* () {
209 const { labels } = parseOwnership(description);
210 if (!hasOwnershipMarker(description)) return false;
211 return yield* hasAlchemyLabels(id, labels);
212 });
213
214export const retryTransient = <A, E extends { readonly _tag: string }, R>(
215 effect: Effect.Effect<A, E, R>,
216) =>
217 effect.pipe(
218 Effect.retry({
219 while: isTransientGcpError,
220 times: 8,
221 schedule: Schedule.exponential("250 millis"),
222 }),
223 );
224
225/** Wait for an Agent Registry operation (control plane: minutes). */
226export const waitForOperation = (operation: registry.Operation) =>
227 waitForGcpOperation(
228 operation,
229 (name) => registry.getProjectsLocationsOperations({ name }),
230 { budget: "10 minutes" },
231 );
232
233/**
234 * Wait for a delete operation. The operation record may already be
235 * garbage-collected (`NotFound`) once the resource is gone.
236 */
237export const waitForDeleteOperation = (operation: registry.Operation) =>
238 waitForGcpOperation(
239 operation,
240 (name) =>
241 registry
242 .getProjectsLocationsOperations({ name })
243 .pipe(
244 Effect.catchTag("NotFound", () =>
245 Effect.succeed<registry.Operation>({ name, done: true }),
246 ),
247 ),
248 { budget: "10 minutes" },
249 );
250
251export const waitForVisible = <A, E, R>(
252 get: Effect.Effect<A | undefined, E, R>,
253) =>
254 get.pipe(
255 Effect.filterOrFail(
256 (value): value is A => value !== undefined,
257 () => new ResourcePending({ name: "" }),
258 ),
259 Effect.retry({
260 while: (error) => error instanceof ResourcePending,
261 times: 8,
262 schedule: Schedule.exponential("250 millis"),
263 }),
264 Effect.catchIf(
265 (error): error is ResourcePending => error instanceof ResourcePending,
266 () => Effect.succeed(undefined),
267 ),
268 );
269
270export class DeleteNotConfirmed extends Data.TaggedError(
271 "GCP.AgentRegistry.DeleteNotConfirmed",
272)<{}> {}
273
274/** Poll until the resource is gone; fails if it is still readable after ~60s. */
275export const waitUntilGone = <A, E, R>(
276 get: Effect.Effect<A | undefined, E, R>,
277) =>
278 get.pipe(
279 Effect.repeat({
280 schedule: Schedule.spaced("2 seconds"),
281 until: (value) => value === undefined,
282 times: 30,
283 }),
284 Effect.flatMap((value): Effect.Effect<void, DeleteNotConfirmed> =>
285 value === undefined ? Effect.void : Effect.fail(new DeleteNotConfirmed()),
286 ),
287 );
288
289const emptyList = <A>() => Effect.succeed<A[]>([]);
290
291export const collectPages = <
292 Page,
293 Item,
294 E extends { readonly _tag: string },
295 R,
296>(
297 pages: Stream.Stream<Page, E, R>,
298 items: (page: Page) => readonly Item[] | null | undefined,
299) =>
300 pages.pipe(
301 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
302 Stream.runCollect,
303 Effect.map((chunk) => Array.from(chunk) as Item[]),
304 );
305
306const listAt = (parent: string) =>
307 parent.length === 0
308 ? emptyList<registry.Binding>()
309 : collectPages(
310 registry.listProjectsLocationsBindings.pages({
311 parent,
312 pageSize: 500,
313 }),
314 (page) => page.bindings,
315 ).pipe(
316 // A missing location has no bindings.
317 Effect.catchTag("NotFound", () => emptyList<registry.Binding>()),
318 );
319
320export const listBindings = (project: string, region: string) =>
321 listAt(`projects/${project}/locations/-`).pipe(
322 Effect.flatMap((items) =>
323 items.length > 0
324 ? Effect.succeed(items)
325 : Effect.all([
326 listAt(locationParent(project, region)),
327 listAt(locationParent(project, "global")),
328 ]).pipe(Effect.map((groups) => groups.flat())),
329 ),
330 );