1import * as dialogflow from "@distilled.cloud/gcp/dialogflow_v3";
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 { GcpEnvironment } from "../Environment.ts";
8import {
9 alchemyLabelKeys,
10 createInternalLabels,
11 hasAlchemyLabels,
12} from "../Labels.ts";
13import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
14
15/**
16 * Dialogflow's "All other requests per minute" quota is shared by the whole
17 * project and distilled's default backoff gives up within ~30s, before the
18 * minute window resets. Lifecycles are idempotent, so re-run them once the
19 * window has rolled over.
20 */
21export const retryQuota = <A, E extends { readonly _tag: string }, R>(
22 effect: Effect.Effect<A, E, R>,
23) =>
24 effect.pipe(
25 Effect.retry({
26 while: (error) => error._tag === "TooManyRequests",
27 times: 6,
28 schedule: Schedule.spaced("20 seconds"),
29 }),
30 );
31
32export const DEFAULT_LOCATION = "global";
33export const DEFAULT_SESSION = "alchemy";
34export const LIST_LOCATIONS = ["global", "us-central1"] as const;
35
36/** Locations scanned by `list`: the fixed set plus the stack region. */
37export const listLocations = (region: string): string[] => [
38 ...new Set<string>([...LIST_LOCATIONS, region]),
39];
40export const MAX_ID_LENGTH = 63;
41export const MAX_DISPLAY_NAME_LENGTH = 64;
42export const MAX_ROUTE_GROUP_DISPLAY_NAME_LENGTH = 30;
43export const MAX_DESCRIPTION_LENGTH = 8000;
44export const MAX_SESSION_ID_LENGTH = 36;
45
46export class DialogflowOperationFailed extends Data.TaggedError(
47 "GCP.Dialogflow.OperationFailed",
48)<{
49 operation: string;
50 message: string;
51}> {}
52
53export class DialogflowOperationPending extends Data.TaggedError(
54 "GCP.Dialogflow.OperationPending",
55)<{
56 operation: string;
57}> {}
58
59export class DialogflowStillExists extends Data.TaggedError(
60 "GCP.Dialogflow.ResourceStillExists",
61)<{
62 name: string;
63}> {}
64
65export const lastSegment = (value: string) => {
66 const trimmed = value.replace(/\/+$/, "");
67 const parts = trimmed.split("/");
68 return parts[parts.length - 1] || trimmed;
69};
70
71export const locationOf = (name: string, fallback = DEFAULT_LOCATION) => {
72 const parts = name.split("/");
73 const index = parts.indexOf("locations");
74 return index >= 0 ? (parts[index + 1] ?? fallback) : fallback;
75};
76
77export const projectOf = (name: string) => {
78 const parts = name.split("/");
79 const index = parts.indexOf("projects");
80 return index >= 0 ? (parts[index + 1] ?? "") : "";
81};
82
83export const parentOf = (name: string) => {
84 const parts = name.split("/").filter((part) => part.length > 0);
85 return parts.slice(0, -2).join("/");
86};
87
88export const collectionParent = (
89 name: string,
90 collection:
91 | "agents"
92 | "playbooks"
93 | "tools"
94 | "sessions"
95 | "entityTypes"
96 | "flows"
97 | "environments"
98 | "webhooks"
99 | "transitionRouteGroups",
100) => {
101 const parts = name.split("/").filter((part) => part.length > 0);
102 const index = parts.lastIndexOf(collection);
103 if (index < 0 || parts[index + 1] === undefined) return parentOf(name);
104 return parts.slice(0, index + 2).join("/");
105};
106
107export const locationParent = (project: string, location: string) =>
108 `projects/${project}/locations/${location}`;
109
110export const normalizeLocation = (location: string | undefined) =>
111 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
112
113export const expandAgent = (
114 value: string,
115 project: string,
116 location: string,
117) =>
118 value.includes("/agents/")
119 ? value
120 : `${locationParent(project, location)}/agents/${value}`;
121
122export const expandName = (
123 value: string,
124 project: string,
125 location: string,
126 collection: "agents" | "flows" | "environments" | "entityTypes",
127 parent?: string,
128) => {
129 if (value.includes("/")) return value;
130 if (parent !== undefined) return `${parent}/${collection}/${value}`;
131 return `${locationParent(project, location)}/${collection}/${value}`;
132};
133
134export const parseResourceName = (name: string, collection: string) => {
135 const parts = name.split("/").filter((part) => part.length > 0);
136 const collectionAt = parts.lastIndexOf(collection);
137 const locationsAt = parts.lastIndexOf("locations");
138 const projectsAt = parts.lastIndexOf("projects");
139 const agentsAt = parts.lastIndexOf("agents");
140 const flowsAt = parts.lastIndexOf("flows");
141 const environmentsAt = parts.lastIndexOf("environments");
142 const sessionsAt = parts.lastIndexOf("sessions");
143 return {
144 project:
145 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
146 location:
147 locationsAt >= 0 && parts[locationsAt + 1]
148 ? parts[locationsAt + 1]!
149 : DEFAULT_LOCATION,
150 agentId: agentsAt >= 0 && parts[agentsAt + 1] ? parts[agentsAt + 1]! : "",
151 agent:
152 agentsAt >= 0 ? parts.slice(0, agentsAt + 2).join("/") : parentOf(name),
153 flowId: flowsAt >= 0 && parts[flowsAt + 1] ? parts[flowsAt + 1]! : "",
154 flow: flowsAt >= 0 ? parts.slice(0, flowsAt + 2).join("/") : "",
155 environmentId:
156 environmentsAt >= 0 && parts[environmentsAt + 1]
157 ? parts[environmentsAt + 1]!
158 : "",
159 environment:
160 environmentsAt >= 0 ? parts.slice(0, environmentsAt + 2).join("/") : "",
161 sessionId:
162 sessionsAt >= 0 && parts[sessionsAt + 1] ? parts[sessionsAt + 1]! : "",
163 session: sessionsAt >= 0 ? parts.slice(0, sessionsAt + 2).join("/") : "",
164 id:
165 collectionAt >= 0 && parts[collectionAt + 1]
166 ? parts[collectionAt + 1]!
167 : lastSegment(name),
168 parent:
169 collectionAt > 0
170 ? parts.slice(0, collectionAt).join("/")
171 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
172 };
173};
174
175export const sameText = (left: string | undefined, right: string | undefined) =>
176 (left ?? "") === (right ?? "");
177
178export const canonical = (value: unknown): unknown => {
179 if (value === undefined || value === null) return undefined;
180 if (typeof value === "boolean" || typeof value === "number") return value;
181 if (typeof value === "string") {
182 return value.length === 0 ? undefined : value;
183 }
184 if (Array.isArray(value)) {
185 const items = value.map(canonical).filter((item) => item !== undefined);
186 return items.length === 0 ? undefined : items;
187 }
188 if (typeof value === "object") {
189 const entries = Object.entries(value as Record<string, unknown>)
190 .map(([key, item]) => [key, canonical(item)] as const)
191 .filter(([, item]) => item !== undefined)
192 .sort(([left], [right]) => left.localeCompare(right));
193 if (entries.length === 0) return undefined;
194 return Object.fromEntries(entries);
195 }
196 return undefined;
197};
198
199export const fingerprint = (value: unknown): string =>
200 JSON.stringify(canonical(value) ?? null);
201
202export const sameJson = (left: unknown, right: unknown) =>
203 fingerprint(left) === fingerprint(right);
204
205export const updateMaskOf = (...fields: Array<string | undefined>) =>
206 fields.filter((field): field is string => field !== undefined).join(",");
207
208export const toResourceId = (
209 id: string,
210 requested: string | undefined,
211 existing: string | undefined,
212 maxLength = MAX_ID_LENGTH,
213) =>
214 Effect.gen(function* () {
215 if (requested !== undefined) return requested;
216 if (existing !== undefined) return existing;
217 const generated = yield* createPhysicalName({
218 id,
219 maxLength,
220 lowercase: true,
221 });
222 const next = /^[a-z]/.test(generated)
223 ? generated
224 : `d${generated}`.slice(0, maxLength);
225 return next.length >= 4 ? next : `${next}xxxx`.slice(0, maxLength);
226 });
227
228const markerOf = (
229 labels: Record<string, string>,
230 stack: string,
231 stage: string,
232 id: string,
233) =>
234 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
235
236const compactMarkerOf = (stack: string, stage: string, id: string) =>
237 `[alc ${stack} ${stage} ${id}]`;
238
239const shrinkMarker = (
240 labels: Record<string, string>,
241 maxLength: number,
242 build: (stack: string, stage: string, id: string) => string,
243) => {
244 let stack = labels[alchemyLabelKeys.stack] ?? "x";
245 let stage = labels[alchemyLabelKeys.stage] ?? "x";
246 let id = labels[alchemyLabelKeys.id] ?? "x";
247 let marker = build(stack, stage, id);
248 while (
249 marker.length > maxLength &&
250 (stack.length > 1 || stage.length > 1 || id.length > 1)
251 ) {
252 if (stack.length >= stage.length && stack.length >= id.length) {
253 stack = stack.slice(0, -1);
254 } else if (stage.length >= id.length) {
255 stage = stage.slice(0, -1);
256 } else {
257 id = id.slice(0, -1);
258 }
259 marker = build(stack, stage, id);
260 }
261 return marker.slice(0, maxLength);
262};
263
264const fitMarker = (labels: Record<string, string>, maxLength: number) =>
265 shrinkMarker(labels, maxLength, (stack, stage, id) =>
266 markerOf(labels, stack, stage, id),
267 );
268
269const fitCompactMarker = (labels: Record<string, string>, maxLength: number) =>
270 shrinkMarker(labels, maxLength, compactMarkerOf);
271
272export const encodeOwnership = (
273 labels: Record<string, string>,
274 text: string | undefined,
275): string => {
276 const marker = fitMarker(labels, MAX_DESCRIPTION_LENGTH);
277 const trimmed = text?.trim();
278 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
279};
280
281export const encodeOwnershipLine = (
282 labels: Record<string, string>,
283 text: string | undefined,
284 maxLength = MAX_DISPLAY_NAME_LENGTH,
285): string => {
286 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
287 // Reserve room for the user's text so the marker shrinks instead of the
288 // text being cut off (a compact marker keeps all three ownership fields).
289 const reserved = trimmed
290 ? Math.min(trimmed.length + 1, Math.max(0, maxLength - 16))
291 : 0;
292 const room = maxLength - reserved;
293 const marker =
294 room < 54 ? fitCompactMarker(labels, room) : fitMarker(labels, room);
295 if (!trimmed) return marker;
296 return `${marker} ${trimmed}`.slice(0, maxLength);
297};
298
299export const parseOwnership = (
300 text: string | undefined,
301): {
302 labels: Record<string, string>;
303 text: string | undefined;
304} => {
305 if (text?.startsWith("[alc ")) {
306 const end = text.indexOf("]");
307 if (end < 0) return { labels: {}, text };
308 const parts = text.slice("[alc ".length, end).trim().split(/\s+/);
309 const labels: Record<string, string> = {};
310 if (parts[0]) labels[alchemyLabelKeys.stack] = parts[0]!;
311 if (parts[1]) labels[alchemyLabelKeys.stage] = parts[1]!;
312 if (parts[2]) labels[alchemyLabelKeys.id] = parts[2]!;
313 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
314 return { labels, text: rest.length > 0 ? rest : undefined };
315 }
316 if (!text?.startsWith("[alchemy ")) {
317 return { labels: {}, text };
318 }
319 const end = text.indexOf("]");
320 if (end < 0) return { labels: {}, text };
321 const labels: Record<string, string> = {};
322 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
323 const eq = part.indexOf("=");
324 if (eq > 0) {
325 labels[part.slice(0, eq)] = part.slice(eq + 1);
326 }
327 }
328 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
329 return { labels, text: rest.length > 0 ? rest : undefined };
330};
331
332export const hasOwnershipMarker = (text: string | undefined) =>
333 Object.keys(parseOwnership(text).labels).some((key) =>
334 key.startsWith("alchemy-"),
335 );
336
337const prefixMatch = (expected: string, observed: string) =>
338 expected === observed ||
339 expected.startsWith(observed) ||
340 observed.startsWith(expected);
341
342export const ownedByAlchemy = (id: string, text: string | undefined) =>
343 Effect.gen(function* () {
344 const expected = yield* createInternalLabels(id);
345 const { labels } = parseOwnership(text);
346 if (!hasOwnershipMarker(text)) return false;
347 const exact = yield* hasAlchemyLabels(id, labels);
348 if (exact) return true;
349 return (
350 prefixMatch(
351 expected[alchemyLabelKeys.stack] ?? "",
352 labels[alchemyLabelKeys.stack] ?? "",
353 ) &&
354 prefixMatch(
355 expected[alchemyLabelKeys.stage] ?? "",
356 labels[alchemyLabelKeys.stage] ?? "",
357 ) &&
358 prefixMatch(
359 expected[alchemyLabelKeys.id] ?? "",
360 labels[alchemyLabelKeys.id] ?? "",
361 )
362 );
363 });
364
365export const internalLabels = (id: string) => createInternalLabels(id);
366
367export const ownershipLabels = (id: string) => createInternalLabels(id);
368
369export const ownershipText = (resource: {
370 displayName?: string;
371 description?: string;
372}) =>
373 hasOwnershipMarker(resource.description)
374 ? resource.description
375 : resource.displayName;
376
377export const getByName = <A, E>(
378 name: string,
379 get: (name: string) => Effect.Effect<A, E, never>,
380) =>
381 name.length === 0
382 ? Effect.succeed(undefined)
383 : get(name).pipe(
384 Effect.catchTag("NotFound" as never, () => Effect.succeed(undefined)),
385 );
386
387export const listPages = <A, E, R>(
388 pages: Stream.Stream<{ items: readonly A[] }, E, R>,
389) =>
390 pages.pipe(
391 Stream.flatMap((page) => Stream.fromIterable(page.items)),
392 Stream.runCollect,
393 Effect.map((chunk) => Array.from(chunk)),
394 Effect.catchTag("NotFound" as never, () => Effect.succeed([] as A[])),
395 );
396
397const collect = <Page, Item, E extends { _tag: string }, R>(
398 pages: Stream.Stream<Page, E, R>,
399 items: (page: Page) => readonly Item[] | undefined,
400) =>
401 pages.pipe(
402 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
403 Stream.runCollect,
404 Effect.map((chunk) => Array.from(chunk)),
405 Effect.catchIf(
406 (error): error is E => error._tag === "NotFound",
407 () => Effect.succeed([] as Item[]),
408 ),
409 );
410
411export const listAgents = (project: string, location?: string) =>
412 Effect.gen(function* () {
413 const locations = location
414 ? [normalizeLocation(location)]
415 : listLocations((yield* GcpEnvironment.current).region);
416 return yield* Effect.forEach(
417 locations,
418 (loc) =>
419 collect(
420 dialogflow.listProjectsLocationsAgents.pages({
421 parent: locationParent(project, loc),
422 pageSize: 100,
423 }),
424 (page) => page.agents,
425 ),
426 { concurrency: 2 },
427 ).pipe(Effect.map((groups) => groups.flat()));
428 });
429
430export const namedAgents = (project: string) =>
431 listAgents(project).pipe(
432 Effect.map((agents) =>
433 agents.filter(
434 (
435 agent,
436 ): agent is dialogflow.GoogleCloudDialogflowCxV3Agent & {
437 name: string;
438 } => typeof agent.name === "string" && agent.name.length > 0,
439 ),
440 ),
441 );
442
443export const listEntityTypes = (agent: string) =>
444 collect(
445 dialogflow.listProjectsLocationsAgentsEntityTypes.pages({
446 parent: agent,
447 pageSize: 100,
448 }),
449 (page) => page.entityTypes,
450 );
451
452export const listEnvironments = (agent: string) =>
453 collect(
454 dialogflow.listProjectsLocationsAgentsEnvironments.pages({
455 parent: agent,
456 pageSize: 100,
457 }),
458 (page) => page.environments,
459 );
460
461export const listExperiments = (environment: string) =>
462 collect(
463 dialogflow.listProjectsLocationsAgentsEnvironmentsExperiments.pages({
464 parent: environment,
465 pageSize: 100,
466 }),
467 (page) => page.experiments,
468 );
469
470export const listSessionEntityTypes = (session: string) =>
471 collect(
472 dialogflow.listProjectsLocationsAgentsEnvironmentsSessionsEntityTypes.pages(
473 {
474 parent: session,
475 pageSize: 100,
476 },
477 ),
478 (page) => page.sessionEntityTypes,
479 );
480
481export const listFlows = (agent: string) =>
482 collect(
483 dialogflow.listProjectsLocationsAgentsFlows.pages({
484 parent: agent,
485 pageSize: 100,
486 }),
487 (page) => page.flows,
488 );
489
490export const listPagesAt = (flow: string) =>
491 collect(
492 dialogflow.listProjectsLocationsAgentsFlowsPages.pages({
493 parent: flow,
494 pageSize: 100,
495 }),
496 (page) => page.pages,
497 );
498
499export const listTransitionRouteGroups = (flow: string) =>
500 collect(
501 dialogflow.listProjectsLocationsAgentsFlowsTransitionRouteGroups.pages({
502 parent: flow,
503 pageSize: 100,
504 }),
505 (page) => page.transitionRouteGroups,
506 );
507
508export const listAgentTransitionRouteGroups = (agent: string) =>
509 collect(
510 dialogflow.listProjectsLocationsAgentsTransitionRouteGroups.pages({
511 parent: agent,
512 pageSize: 100,
513 }),
514 (page) => page.transitionRouteGroups,
515 );
516
517export const listWebhooks = (agent: string) =>
518 collect(
519 dialogflow.listProjectsLocationsAgentsWebhooks.pages({
520 parent: agent,
521 pageSize: 100,
522 }),
523 (page) => page.webhooks,
524 );
525
526export const listVersions = (flow: string) =>
527 collect(
528 dialogflow.listProjectsLocationsAgentsFlowsVersions.pages({
529 parent: flow,
530 pageSize: 100,
531 }),
532 (page) => page.versions,
533 );
534
535export const listSecuritySettings = (parent: string) =>
536 collect(
537 dialogflow.listProjectsLocationsSecuritySettings.pages({
538 parent,
539 pageSize: 100,
540 }),
541 (page) => page.securitySettings,
542 );
543
544export const resourceNameFromOperation = (
545 operation: dialogflow.GoogleLongrunningOperation,
546): string | undefined => {
547 const response = operation.response;
548 const responseName = response?.name;
549 if (typeof responseName === "string" && responseName.length > 0) {
550 return responseName;
551 }
552 const metadata = operation.metadata;
553 const target = metadata?.target;
554 if (typeof target === "string" && target.length > 0) {
555 return target;
556 }
557 const metadataName = metadata?.name;
558 if (typeof metadataName === "string" && metadataName.length > 0) {
559 return metadataName;
560 }
561 return undefined;
562};
563
564/** Wait for an operation through the shared GCP waiter. */
565export const waitForOperation = (
566 operation: dialogflow.GoogleLongrunningOperation,
567 options?: { notFoundOk?: boolean; alreadyExistsOk?: boolean },
568) =>
569 waitForGcpOperation(
570 operation,
571 (name) =>
572 dialogflow.getProjectsLocationsOperations({ name }).pipe(
573 Effect.catchTag("NotFound", (error) =>
574 options?.notFoundOk === true
575 ? Effect.succeed<dialogflow.GoogleLongrunningOperation>({
576 name,
577 done: true,
578 })
579 : Effect.fail(error),
580 ),
581 ),
582 { budget: "10 minutes" },
583 ).pipe(
584 // ALREADY_EXISTS (6): a concurrent create won the race. NOT_FOUND (5)
585 // is success for a delete.
586 Effect.catchIf(
587 (error) =>
588 error._tag === "GCP.OperationFailed" &&
589 ((options?.alreadyExistsOk !== false && error.code === 6) ||
590 (options?.notFoundOk === true && error.code === 5)),
591 () => Effect.void,
592 ),
593 // Re-read the finished operation for its typed response and metadata.
594 Effect.flatMap(() =>
595 operation.name === undefined || operation.name.length === 0
596 ? Effect.succeed(operation)
597 : dialogflow
598 .getProjectsLocationsOperations({ name: operation.name })
599 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(operation))),
600 ),
601 );
602
603export class DialogflowNotYetReadable extends Data.TaggedError(
604 "GCP.Dialogflow.NotYetReadable",
605)<{ name: string }> {}
606
607/** Some Dialogflow creates are not readable for a few seconds afterwards. */
608export const waitUntilReadable = <A, E extends { readonly _tag: string }, R>(
609 name: string,
610 get: (name: string) => Effect.Effect<A | undefined, E, R>,
611) =>
612 get(name).pipe(
613 Effect.flatMap((value) =>
614 value === undefined
615 ? Effect.fail(new DialogflowNotYetReadable({ name }))
616 : Effect.succeed(value),
617 ),
618 Effect.retry({
619 while: (error) => error._tag === "GCP.Dialogflow.NotYetReadable",
620 times: 10,
621 schedule: Schedule.spaced("2 seconds"),
622 }),
623 );
624
625export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
626 name: string,
627 get: (name: string) => Effect.Effect<A | undefined, E, R>,
628) =>
629 get(name).pipe(
630 Effect.flatMap((value) =>
631 value === undefined
632 ? Effect.void
633 : Effect.fail(new DialogflowStillExists({ name })),
634 ),
635 Effect.retry({
636 while: (error) => error._tag === "GCP.Dialogflow.ResourceStillExists",
637 times: 10,
638 schedule: Schedule.spaced("1 second"),
639 }),
640 );