1import * as ccaip from "@distilled.cloud/gcp/contactcenteraiplatform_v1alpha1";
2import { Retry as GcpRetry } from "@distilled.cloud/gcp/Retry";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import { tagRecord } from "../../Tags.ts";
10import { stripInternalLabels } from "../Labels.ts";
11import { isTransientGcpError } from "../Errors.ts";
12
13const noRetryLayer = Layer.succeed(GcpRetry, { while: () => false });
14
15export const DEFAULT_INSTANCE_SIZE = "DEV_SMALL";
16export const MAX_ID_LENGTH = 63;
17export const MAX_DOMAIN_PREFIX_LENGTH = 16;
18export const MIN_DOMAIN_PREFIX_LENGTH = 2;
19
20export class ContactCenterNotResolved extends Data.TaggedError(
21 "GCP.ContactCenterAIPlatform.ContactCenterNotResolved",
22)<{
23 name: string;
24}> {}
25
26export class ContactCenterNotReady extends Data.TaggedError(
27 "GCP.ContactCenterAIPlatform.ContactCenterNotReady",
28)<{
29 name: string;
30 state: string;
31}> {}
32
33export class ContactCenterFailed extends Data.TaggedError(
34 "GCP.ContactCenterAIPlatform.ContactCenterFailed",
35)<{
36 name: string;
37 state: string;
38}> {}
39
40export class ContactCenterStillExists extends Data.TaggedError(
41 "GCP.ContactCenterAIPlatform.ContactCenterStillExists",
42)<{
43 name: string;
44}> {}
45
46export const lastSegment = (value: string) => {
47 const trimmed = value.replace(/\/+$/, "");
48 const parts = trimmed.split("/");
49 return parts[parts.length - 1] || trimmed;
50};
51
52export const locationParent = (project: string, location: string) =>
53 `projects/${project}/locations/${location}`;
54
55export const normalizeLocation = (
56 location: string | undefined,
57 defaultLocation: string,
58) => lastSegment(location ?? defaultLocation).toLowerCase();
59
60export const rfc1035 = (
61 name: string,
62 maxLength: number,
63 fallback: string,
64): string => {
65 let next = name
66 .toLowerCase()
67 .replace(/[^a-z0-9-]/g, "-")
68 .replace(/-+/g, "-")
69 .replace(/^-+|-+$/g, "");
70 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "c"}${next}`;
71 next = next.slice(0, maxLength).replace(/-+$/g, "");
72 if (next.length === 0) return fallback.slice(0, maxLength);
73 if (!/[a-z0-9]$/.test(next)) {
74 next = `${next.slice(0, maxLength - 1)}0`;
75 }
76 return next.slice(0, maxLength);
77};
78
79export const domainPrefixOf = (name: string): string => {
80 const next = rfc1035(name, MAX_DOMAIN_PREFIX_LENGTH, "cc");
81 return next.length >= MIN_DOMAIN_PREFIX_LENGTH
82 ? next
83 : `${next}x`.slice(0, MAX_DOMAIN_PREFIX_LENGTH);
84};
85
86export const toPhysicalId = (
87 id: string,
88 explicit: string | undefined,
89 existing: string | undefined,
90) =>
91 Effect.gen(function* () {
92 if (explicit !== undefined) return rfc1035(explicit, MAX_ID_LENGTH, "cc");
93 if (existing !== undefined) return existing;
94 return rfc1035(
95 yield* createPhysicalName({
96 id,
97 maxLength: MAX_ID_LENGTH,
98 lowercase: true,
99 }),
100 MAX_ID_LENGTH,
101 "cc",
102 );
103 });
104
105export const toDomainPrefix = (
106 explicit: string | undefined,
107 existing: string | undefined,
108 id: string,
109) =>
110 Effect.gen(function* () {
111 if (explicit !== undefined) return domainPrefixOf(explicit);
112 if (existing !== undefined) return existing;
113 return domainPrefixOf(
114 yield* createPhysicalName({
115 id,
116 maxLength: MAX_DOMAIN_PREFIX_LENGTH,
117 lowercase: true,
118 }),
119 );
120 });
121
122export const parseName = (name: string, defaultLocation: string) => {
123 const parts = name.split("/").filter((part) => part.length > 0);
124 const collectionAt = parts.lastIndexOf("contactCenters");
125 const locationsAt = parts.lastIndexOf("locations");
126 const projectsAt = parts.lastIndexOf("projects");
127 return {
128 project:
129 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
130 location:
131 locationsAt >= 0 && parts[locationsAt + 1]
132 ? parts[locationsAt + 1]!
133 : defaultLocation,
134 id:
135 collectionAt >= 0 && parts[collectionAt + 1]
136 ? parts[collectionAt + 1]!
137 : lastSegment(name),
138 };
139};
140
141export const userLabels = (
142 labels: Record<string, string | undefined> | null | undefined,
143): Record<string, string> => stripInternalLabels(tagRecord(labels));
144
145export const hasAlchemyLabelMap = (
146 labels: Record<string, string | undefined> | null | undefined,
147) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
148
149export const canonical = (value: unknown): unknown => {
150 if (value === undefined || value === null) return undefined;
151 if (typeof value === "boolean" || typeof value === "number") return value;
152 if (typeof value === "string") {
153 return value.length === 0 ? undefined : value;
154 }
155 if (Array.isArray(value)) {
156 const items = value.map(canonical).filter((item) => item !== undefined);
157 return items.length === 0 ? undefined : items;
158 }
159 if (typeof value === "object") {
160 const entries = Object.entries(value as Record<string, unknown>)
161 .map(([key, item]) => [key, canonical(item)] as const)
162 .filter(([, item]) => item !== undefined)
163 .sort(([left], [right]) => left.localeCompare(right));
164 if (entries.length === 0) return undefined;
165 return Object.fromEntries(entries);
166 }
167 return undefined;
168};
169
170export const fingerprint = (value: unknown): string =>
171 JSON.stringify(canonical(value) ?? null);
172
173export const sameJson = (left: unknown, right: unknown) =>
174 fingerprint(left) === fingerprint(right);
175
176export const sameText = (left: string | undefined, right: string | undefined) =>
177 (left ?? "") === (right ?? "");
178
179export const sameBool = (
180 left: boolean | undefined,
181 right: boolean | undefined,
182) => (left === true) === (right === true);
183
184export const updateMaskOf = (...fields: Array<string | false | undefined>) =>
185 fields
186 .filter((field): field is string => typeof field === "string")
187 .join(",");
188
189export const replaceOnIdentity = (input: {
190 previousId?: string;
191 nextId?: string;
192 previousLocation?: string;
193 nextLocation?: string;
194 extra?: boolean;
195}) => {
196 if (input.extra === true) {
197 return { action: "replace" as const, deleteFirst: false };
198 }
199 if (
200 input.previousId !== undefined &&
201 input.nextId !== undefined &&
202 input.previousId !== input.nextId
203 ) {
204 return { action: "replace" as const, deleteFirst: false };
205 }
206 if (
207 input.previousLocation !== undefined &&
208 input.nextLocation !== undefined &&
209 input.previousLocation !== input.nextLocation
210 ) {
211 return { action: "replace" as const, deleteFirst: false };
212 }
213 return undefined;
214};
215
216export const retryTransient = <A, E extends { readonly _tag: string }, R>(
217 effect: Effect.Effect<A, E, R>,
218) =>
219 effect.pipe(
220 Effect.retry({
221 while: isTransientGcpError,
222 times: 8,
223 schedule: Schedule.exponential("250 millis"),
224 }),
225 );
226
227export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
228 get: Effect.Effect<A, E, R>,
229 name: string,
230): Effect.Effect<Exclude<A, undefined>, E | ContactCenterNotResolved, R> =>
231 get.pipe(
232 Effect.filterOrFail(
233 (value): value is Exclude<A, undefined> => value !== undefined,
234 () => new ContactCenterNotResolved({ name }),
235 ),
236 Effect.retry({
237 while: (error) =>
238 error._tag === "GCP.ContactCenterAIPlatform.ContactCenterNotResolved",
239 times: 8,
240 schedule: Schedule.spaced("1 second"),
241 }),
242 );
243
244export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
245 get: Effect.Effect<A, E, R>,
246 name: string,
247): Effect.Effect<void, E | ContactCenterStillExists, R> =>
248 get.pipe(
249 Effect.filterOrFail(
250 (value) => value === undefined,
251 () => new ContactCenterStillExists({ name }),
252 ),
253 Effect.asVoid,
254 Effect.retry({
255 while: (error) =>
256 error._tag === "GCP.ContactCenterAIPlatform.ContactCenterStillExists",
257 times: 10,
258 schedule: Schedule.spaced("2 seconds"),
259 }),
260 );
261
262const FAILED_STATES = new Set(["STATE_FAILED", "STATE_TERMINATING_FAILED"]);
263
264const READY_STATES = new Set([
265 "STATE_DEPLOYED",
266 "STATE_IN_GRACE_PERIOD",
267 "STATE_DEGRADED",
268]);
269
270export const isTerminated = (state: string | undefined) =>
271 (state ?? "").toUpperCase() === "STATE_TERMINATED";
272
273const stateOfUpper = (state: string | undefined) => (state ?? "").toUpperCase();
274
275export const waitUntilReady = <A, E extends { readonly _tag: string }, R>(
276 get: Effect.Effect<A, E, R>,
277 name: string,
278 stateOf: (value: Exclude<A, undefined>) => string | undefined,
279): Effect.Effect<
280 Exclude<A, undefined>,
281 E | ContactCenterNotResolved | ContactCenterFailed | ContactCenterNotReady,
282 R
283> =>
284 get.pipe(
285 Effect.filterOrFail(
286 (value): value is Exclude<A, undefined> => value !== undefined,
287 () => new ContactCenterNotResolved({ name }),
288 ),
289 Effect.filterOrFail(
290 (value) => !FAILED_STATES.has(stateOfUpper(stateOf(value))),
291 (value) =>
292 new ContactCenterFailed({
293 name,
294 state: stateOfUpper(stateOf(value)),
295 }),
296 ),
297 Effect.filterOrFail(
298 (value) => READY_STATES.has(stateOfUpper(stateOf(value))),
299 (value) =>
300 new ContactCenterNotReady({
301 name,
302 state: stateOfUpper(stateOf(value)),
303 }),
304 ),
305 Effect.retry({
306 while: (error) =>
307 error._tag === "GCP.ContactCenterAIPlatform.ContactCenterNotReady" ||
308 error._tag === "GCP.ContactCenterAIPlatform.ContactCenterNotResolved",
309 times: 10,
310 schedule: Schedule.spaced("8 seconds"),
311 }),
312 );
313
314export const releaseChannelOf = (
315 item: Pick<ccaip.ContactCenter, "early" | "normal" | "critical">,
316): "early" | "normal" | "critical" | undefined => {
317 if (item.critical !== undefined) return "critical";
318 if (item.early !== undefined) return "early";
319 if (item.normal !== undefined) return "normal";
320 return undefined;
321};
322
323const emptyContactCenters = Effect.succeed<ccaip.ContactCenter[]>([]);
324
325export const listContactCenters = (parent: string) =>
326 parent.length === 0
327 ? emptyContactCenters
328 : ccaip.listProjectsLocationsContactCenters
329 .pages({ parent, pageSize: 1000 })
330 .pipe(
331 Stream.flatMap((page) =>
332 Stream.fromIterable(page.contactCenters ?? []),
333 ),
334 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
335 Stream.runCollect,
336 Effect.map((chunk) => Array.from(chunk)),
337 Effect.provide(noRetryLayer),
338 Effect.catchTag("NotFound", () => emptyContactCenters),
339 );
340
341const FALLBACK_LOCATIONS = [
342 "us-central1",
343 "us-east1",
344 "europe-west1",
345 "asia-southeast1",
346] as const;
347
348const listLocationIds = (project: string) =>
349 ccaip.listProjectsLocations
350 .pages({ name: `projects/${project}`, pageSize: 100 })
351 .pipe(
352 Stream.flatMap((page) => Stream.fromIterable(page.locations ?? [])),
353 Stream.map(
354 (location) => location.locationId || lastSegment(location.name ?? ""),
355 ),
356 Stream.filter((id) => id.length > 0 && id !== "-"),
357 Stream.runCollect,
358 Effect.map((chunk) => Array.from(new Set(chunk))),
359 );
360
361const listAcrossLocations = (project: string, region: string) =>
362 Effect.gen(function* () {
363 const discovered = yield* listLocationIds(project);
364 const locations =
365 discovered.length > 0
366 ? discovered
367 : [...new Set([region, ...FALLBACK_LOCATIONS])];
368 const pages = yield* Effect.forEach(
369 locations,
370 (location) => listContactCenters(locationParent(project, location)),
371 { concurrency: 4 },
372 );
373 return pages.flat();
374 });
375
376export const listOwnedContactCenters = (project: string, region: string) =>
377 listContactCenters(`projects/${project}/locations/-`).pipe(
378 Effect.catchIf(
379 (error) =>
380 error._tag === "ServiceUnavailable" ||
381 error._tag === "InternalServerError" ||
382 error._tag === "BadGateway" ||
383 error._tag === "GatewayTimeout",
384 () => listAcrossLocations(project, region),
385 ),
386 );