1import * as jobs from "@distilled.cloud/gcp/jobs_v4";
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 {
8 alchemyLabelKeys,
9 createInternalLabels,
10 hasAlchemyLabels,
11} from "../Labels.ts";
12
13export const MAX_EXTERNAL_ID_LENGTH = 255;
14export const MAX_DISPLAY_NAME_LENGTH = 255;
15export const MAX_DESCRIPTION_LENGTH = 100_000;
16export const MAX_REQUISITION_ID_LENGTH = 255;
17export const MAX_TITLE_LENGTH = 500;
18export const DEFAULT_LANGUAGE = "en-US";
19
20const markerOf = (
21 _labels: Record<string, string>,
22 stack: string,
23 stage: string,
24 id: string,
25) =>
26 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
27
28const fitMarker = (labels: Record<string, string>, maxLength: number) => {
29 let stack = labels[alchemyLabelKeys.stack] ?? "x";
30 let stage = labels[alchemyLabelKeys.stage] ?? "x";
31 let id = labels[alchemyLabelKeys.id] ?? "x";
32 let marker = markerOf(labels, stack, stage, id);
33 while (
34 marker.length > maxLength &&
35 (stack.length > 1 || stage.length > 1 || id.length > 1)
36 ) {
37 if (stack.length >= stage.length && stack.length >= id.length) {
38 stack = stack.slice(0, -1);
39 } else if (stage.length >= id.length) {
40 stage = stage.slice(0, -1);
41 } else {
42 id = id.slice(0, -1);
43 }
44 marker = markerOf(labels, stack, stage, id);
45 }
46 return marker.slice(0, maxLength);
47};
48
49export const encodeOwnership = (
50 labels: Record<string, string>,
51 text: string | undefined,
52 maxLength = MAX_DESCRIPTION_LENGTH,
53): string => {
54 const marker = fitMarker(labels, Math.min(8000, maxLength));
55 const trimmed = text?.trim();
56 const combined =
57 trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
58 return combined.slice(0, maxLength);
59};
60
61export const encodeOwnershipLine = (
62 labels: Record<string, string>,
63 text: string | undefined,
64 maxLength = MAX_EXTERNAL_ID_LENGTH,
65): string => {
66 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
67 if (!trimmed) return fitMarker(labels, maxLength);
68 const minMarker = 24;
69 const reserved = Math.min(
70 trimmed.length + 1,
71 Math.max(0, maxLength - minMarker),
72 );
73 const marker = fitMarker(labels, maxLength - reserved);
74 return `${marker} ${trimmed}`.slice(0, maxLength);
75};
76
77export const parseOwnership = (
78 text: string | undefined,
79): {
80 labels: Record<string, string>;
81 text: string | undefined;
82} => {
83 if (!text?.startsWith("[alchemy ")) {
84 return { labels: {}, text };
85 }
86 const end = text.indexOf("]");
87 if (end < 0) return { labels: {}, text };
88 const labels: Record<string, string> = {};
89 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
90 const eq = part.indexOf("=");
91 if (eq > 0) {
92 labels[part.slice(0, eq)] = part.slice(eq + 1);
93 }
94 }
95 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
96 return { labels, text: rest.length > 0 ? rest : undefined };
97};
98
99export const hasOwnershipMarker = (text: string | undefined) => {
100 if (
101 Object.keys(parseOwnership(text).labels).some((key) =>
102 key.startsWith("alchemy-"),
103 )
104 ) {
105 return true;
106 }
107 return (text ?? "").toLowerCase().includes("alchemy-");
108};
109
110const prefixMatch = (expected: string, observed: string) =>
111 expected === observed ||
112 expected.startsWith(observed) ||
113 observed.startsWith(expected);
114
115export const ownedByAlchemy = (id: string, text: string | undefined) =>
116 Effect.gen(function* () {
117 const expected = yield* createInternalLabels(id);
118 const { labels } = parseOwnership(text);
119 if (!hasOwnershipMarker(text)) return false;
120 const exact = yield* hasAlchemyLabels(id, labels);
121 if (exact) return true;
122 return (
123 prefixMatch(
124 expected[alchemyLabelKeys.stack] ?? "",
125 labels[alchemyLabelKeys.stack] ?? "",
126 ) &&
127 prefixMatch(
128 expected[alchemyLabelKeys.stage] ?? "",
129 labels[alchemyLabelKeys.stage] ?? "",
130 ) &&
131 prefixMatch(
132 expected[alchemyLabelKeys.id] ?? "",
133 labels[alchemyLabelKeys.id] ?? "",
134 )
135 );
136 });
137
138export const ownershipLabels = (id: string) => createInternalLabels(id);
139
140export const lastSegment = (value: string) => {
141 const trimmed = value.replace(/\/+$/, "");
142 const parts = trimmed.split("/");
143 return parts[parts.length - 1] || trimmed;
144};
145
146export const parentOf = (name: string) => {
147 const parts = name.split("/").filter((part) => part.length > 0);
148 return parts.slice(0, -2).join("/");
149};
150
151export const projectParent = (project: string) => `projects/${project}`;
152
153export const tenantNameOf = (project: string, tenantId: string) => {
154 if (tenantId.length === 0) return "";
155 if (tenantId.includes("/tenants/")) return tenantId.replace(/\/+$/, "");
156 return `projects/${project}/tenants/${lastSegment(tenantId)}`;
157};
158
159export const companyNameOf = (parent: string, companyId: string) => {
160 if (companyId.length === 0) return "";
161 if (companyId.includes("/companies/")) return companyId.replace(/\/+$/, "");
162 return `${parent}/companies/${lastSegment(companyId)}`;
163};
164
165export const jobNameOf = (parent: string, jobId: string) => {
166 if (jobId.length === 0) return "";
167 if (jobId.includes("/jobs/")) return jobId.replace(/\/+$/, "");
168 return `${parent}/jobs/${lastSegment(jobId)}`;
169};
170
171export const parseTenantName = (name: string, fallbackProject: string) => {
172 const parts = name.split("/").filter((part) => part.length > 0);
173 const tenantsAt = parts.lastIndexOf("tenants");
174 const projectsAt = parts.lastIndexOf("projects");
175 return {
176 project:
177 projectsAt >= 0 && parts[projectsAt + 1]
178 ? parts[projectsAt + 1]!
179 : fallbackProject,
180 tenantId:
181 tenantsAt >= 0 && parts[tenantsAt + 1]
182 ? parts[tenantsAt + 1]!
183 : lastSegment(name),
184 name,
185 };
186};
187
188export const parseCompanyName = (name: string, fallbackProject: string) => {
189 const parsed = parseTenantName(name, fallbackProject);
190 const parts = name.split("/").filter((part) => part.length > 0);
191 const companiesAt = parts.lastIndexOf("companies");
192 const tenantsAt = parts.lastIndexOf("tenants");
193 return {
194 ...parsed,
195 parent:
196 tenantsAt >= 0 && parts[tenantsAt + 1]
197 ? parts.slice(0, tenantsAt + 2).join("/")
198 : parentOf(name),
199 companyId:
200 companiesAt >= 0 && parts[companiesAt + 1]
201 ? parts[companiesAt + 1]!
202 : lastSegment(name),
203 };
204};
205
206export const parseJobName = (name: string, fallbackProject: string) => {
207 const parsed = parseTenantName(name, fallbackProject);
208 const parts = name.split("/").filter((part) => part.length > 0);
209 const jobsAt = parts.lastIndexOf("jobs");
210 const tenantsAt = parts.lastIndexOf("tenants");
211 return {
212 ...parsed,
213 parent:
214 tenantsAt >= 0 && parts[tenantsAt + 1]
215 ? parts.slice(0, tenantsAt + 2).join("/")
216 : parentOf(name),
217 jobId:
218 jobsAt >= 0 && parts[jobsAt + 1] ? parts[jobsAt + 1]! : lastSegment(name),
219 };
220};
221
222export const sameText = (left: string | undefined, right: string | undefined) =>
223 (left ?? "") === (right ?? "");
224
225export const sameBoolean = (
226 left: boolean | undefined,
227 right: boolean | undefined,
228) => (left ?? false) === (right ?? false);
229
230export const jsonEqual = (left: unknown, right: unknown) =>
231 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
232
233export const sameStringList = (
234 left: readonly string[] | undefined,
235 right: readonly string[] | undefined,
236) =>
237 jsonEqual(
238 [...(left ?? [])].slice().sort(),
239 [...(right ?? [])].slice().sort(),
240 );
241
242export const updateMaskOf = (...fields: Array<string | undefined>) =>
243 fields.filter((field): field is string => field !== undefined).join(",");
244
245export const replaceOnIdentity = (input: {
246 previousId?: string;
247 nextId?: string;
248 previousParent?: string;
249 nextParent?: string;
250 extra?: boolean;
251}) => {
252 if (input.extra === true) {
253 return { action: "replace" as const, deleteFirst: false };
254 }
255 if (
256 input.previousId !== undefined &&
257 input.nextId !== undefined &&
258 input.previousId !== input.nextId
259 ) {
260 return { action: "replace" as const, deleteFirst: false };
261 }
262 if (
263 input.previousParent !== undefined &&
264 input.nextParent !== undefined &&
265 input.previousParent !== input.nextParent
266 ) {
267 return { action: "replace" as const, deleteFirst: false };
268 }
269 return undefined;
270};
271
272export const toGeneratedName = (
273 id: string,
274 requested: string | undefined,
275 existing: string | undefined,
276 maxLength = 80,
277) =>
278 Effect.gen(function* () {
279 if (requested !== undefined && requested.length > 0) {
280 return requested.slice(0, maxLength);
281 }
282 if (existing !== undefined && existing.length > 0) {
283 return existing.slice(0, maxLength);
284 }
285 const generated = yield* createPhysicalName({
286 id,
287 maxLength,
288 lowercase: true,
289 });
290 const next = /^[a-z]/.test(generated)
291 ? generated
292 : `j${generated}`.slice(0, maxLength);
293 return next.length >= 4 ? next : `${next}xxxx`.slice(0, maxLength);
294 });
295
296const emptyList = <A>() => Effect.succeed([] as A[]);
297
298export const retryTransient = <A, E extends { readonly _tag: string }, R>(
299 effect: Effect.Effect<A, E, R>,
300) =>
301 effect.pipe(
302 Effect.retry({
303 while: (error) =>
304 error._tag === "Conflict" ||
305 error._tag === "BadRequest" ||
306 error._tag === "NotFound",
307 times: 8,
308 schedule: Schedule.exponential("250 millis"),
309 }),
310 );
311
312export class DeleteNotConfirmed extends Data.TaggedError(
313 "GCP.Jobs.DeleteNotConfirmed",
314)<{}> {}
315
316/** Poll until the resource is gone; fails if it is still readable after ~60s. */
317export const waitUntilGone = <A, E, R>(
318 get: Effect.Effect<A | undefined, E, R>,
319) =>
320 get.pipe(
321 Effect.repeat({
322 schedule: Schedule.spaced("2 seconds"),
323 until: (value) => value === undefined,
324 times: 30,
325 }),
326 Effect.flatMap((value): Effect.Effect<void, DeleteNotConfirmed> =>
327 value === undefined ? Effect.void : Effect.fail(new DeleteNotConfirmed()),
328 ),
329 );
330
331const collectPages = <Page, Item, E, R>(
332 pages: Stream.Stream<Page, E, R>,
333 items: (page: Page) => readonly Item[] | null | undefined,
334) =>
335 pages.pipe(
336 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
337 Stream.runCollect,
338 Effect.map((chunk): Item[] => Array.from(chunk)),
339 );
340
341export const getTenant = (name: string) =>
342 name.length === 0
343 ? Effect.succeed(undefined)
344 : jobs
345 .getProjectsTenants({ name })
346 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
347
348export const getCompany = (name: string) =>
349 name.length === 0
350 ? Effect.succeed(undefined)
351 : jobs
352 .getProjectsTenantsCompanies({ name })
353 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
354
355export const getJob = (name: string) =>
356 name.length === 0
357 ? Effect.succeed(undefined)
358 : jobs
359 .getProjectsTenantsJobs({ name })
360 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
361
362export const listTenants = (project: string) =>
363 collectPages(
364 jobs.listProjectsTenants.pages({
365 parent: projectParent(project),
366 pageSize: 100,
367 }),
368 (page) => page.tenants,
369 ).pipe(Effect.catchTag("NotFound", () => emptyList<jobs.Tenant>()));
370
371export const listCompanies = (parent: string) =>
372 parent.length === 0
373 ? emptyList<jobs.Company>()
374 : collectPages(
375 jobs.listProjectsTenantsCompanies.pages({
376 parent,
377 pageSize: 100,
378 }),
379 (page) => page.companies,
380 ).pipe(Effect.catchTag("NotFound", () => emptyList<jobs.Company>()));
381
382export const listJobs = (parent: string, companyName: string) =>
383 parent.length === 0 || companyName.length === 0
384 ? emptyList<jobs.Job>()
385 : collectPages(
386 jobs.listProjectsTenantsJobs.pages({
387 parent,
388 filter: `companyName = "${companyName}" AND status = "ALL"`,
389 pageSize: 100,
390 jobView: "JOB_VIEW_FULL",
391 }),
392 (page) => page.jobs,
393 ).pipe(Effect.catchTag("NotFound", () => emptyList<jobs.Job>()));
394
395export const listOwnedTenants = (project: string) =>
396 listTenants(project).pipe(
397 Effect.map((tenants) =>
398 tenants.filter((tenant) => hasOwnershipMarker(tenant.externalId)),
399 ),
400 );
401
402export const listOwnedCompanies = (project: string) =>
403 Effect.gen(function* () {
404 const tenants = yield* listTenants(project);
405 const pages = yield* Effect.forEach(
406 tenants,
407 (tenant) =>
408 tenant.name ? listCompanies(tenant.name) : emptyList<jobs.Company>(),
409 { concurrency: 4 },
410 );
411 return pages
412 .flat()
413 .filter(
414 (company) =>
415 hasOwnershipMarker(company.displayName) ||
416 hasOwnershipMarker(company.externalId),
417 );
418 });
419
420export const listOwnedJobs = (project: string) =>
421 Effect.gen(function* () {
422 const tenants = yield* listTenants(project);
423 const companies = yield* Effect.forEach(
424 tenants,
425 (tenant) =>
426 tenant.name ? listCompanies(tenant.name) : emptyList<jobs.Company>(),
427 { concurrency: 4 },
428 );
429 const listed = companies.flat();
430 const pages = yield* Effect.forEach(
431 listed,
432 (company) => {
433 const parent = company.name ? parentOf(company.name) : "";
434 return company.name
435 ? listJobs(parent, company.name)
436 : emptyList<jobs.Job>();
437 },
438 { concurrency: 4 },
439 );
440 return pages
441 .flat()
442 .filter(
443 (job) =>
444 hasOwnershipMarker(job.description) ||
445 hasOwnershipMarker(job.requisitionId),
446 );
447 });
448
449export const findOwnedTenant = (project: string, id: string) =>
450 Effect.gen(function* () {
451 const tenants = yield* listTenants(project);
452 for (const tenant of tenants) {
453 if (yield* ownedByAlchemy(id, tenant.externalId)) {
454 return tenant;
455 }
456 }
457 return undefined;
458 });
459
460export const findOwnedCompany = (parent: string, id: string) =>
461 Effect.gen(function* () {
462 const companies = yield* listCompanies(parent);
463 for (const company of companies) {
464 if (
465 (yield* ownedByAlchemy(id, company.displayName)) ||
466 (yield* ownedByAlchemy(id, company.externalId))
467 ) {
468 return company;
469 }
470 }
471 return undefined;
472 });
473
474export const findOwnedJob = (parent: string, id: string) =>
475 Effect.gen(function* () {
476 const companies = yield* listCompanies(parent);
477 for (const company of companies) {
478 if (!company.name) continue;
479 const listed = yield* listJobs(parent, company.name);
480 for (const job of listed) {
481 if (
482 (yield* ownedByAlchemy(id, job.description)) ||
483 (yield* ownedByAlchemy(id, job.requisitionId))
484 ) {
485 return job;
486 }
487 }
488 }
489 return undefined;
490 });
491
492export const deleteJob = (name: string) =>
493 name.length === 0
494 ? Effect.void
495 : jobs
496 .deleteProjectsTenantsJobs({ name })
497 .pipe(Effect.catchTag("NotFound", () => Effect.void));
498
499export const deleteCompany = (name: string) =>
500 Effect.gen(function* () {
501 if (name.length === 0) return;
502 const parent = parentOf(name);
503 const listed = yield* listJobs(parent, name);
504 yield* Effect.forEach(
505 listed,
506 (job) => (job.name ? deleteJob(job.name) : Effect.void),
507 { concurrency: 4 },
508 );
509 yield* retryTransient(
510 jobs
511 .deleteProjectsTenantsCompanies({ name })
512 .pipe(Effect.catchTag("NotFound", () => Effect.void)),
513 );
514 });
515
516export const deleteTenant = (name: string) =>
517 Effect.gen(function* () {
518 if (name.length === 0) return;
519 const companies = yield* listCompanies(name);
520 yield* Effect.forEach(
521 companies,
522 (company) => (company.name ? deleteCompany(company.name) : Effect.void),
523 { concurrency: 4 },
524 );
525 yield* retryTransient(
526 jobs
527 .deleteProjectsTenants({ name })
528 .pipe(Effect.catchTag("NotFound", () => Effect.void)),
529 );
530 });