1import * as aiplatform from "@distilled.cloud/gcp/aiplatform_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import { listLocations } from "./names.ts";
13import { waitForOperation } from "./operations.ts";
14import {
15 encodeOwnership,
16 hasOwnershipMarker,
17 lastSegment,
18 locationOf,
19 locationParent,
20 ownedByAlchemy,
21 parentOf,
22 parseOwnership,
23 toResourceId,
24} from "./ownership.ts";
25import { createInternalLabels } from "../Labels.ts";
26
27export type TimeSeriesValueType =
28 | "SCALAR"
29 | "TENSOR"
30 | "BLOB_SEQUENCE"
31 | (string & {});
32
33export type TensorboardsExperimentsRunsTimeSeriesProps = {
34 /**
35 * Parent TensorboardRun resource name
36 * (`projects/{project}/locations/{location}/tensorboards/{tensorboard}/experiments/{experiment}/runs/{run}`).
37 * Immutable — changing it replaces the time series.
38 */
39 parent: string;
40 /**
41 * Time series id (the `{time_series}` segment, `[a-z0-9]{0,128}`). If
42 * omitted, Vertex AI assigns one. Immutable.
43 */
44 timeSeriesId?: string;
45 /**
46 * User-facing display name. Must be unique among time series of the
47 * parent run.
48 */
49 displayName?: string;
50 /**
51 * Human-readable description. TensorboardTimeSeries has no labels field,
52 * so Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
53 * from attributes.
54 */
55 description?: string;
56 /**
57 * Value type. Immutable.
58 * @default "SCALAR"
59 */
60 valueType?: TimeSeriesValueType;
61 /**
62 * Plugin name this time series belongs to (`scalars`, `tensors`, …).
63 * Immutable.
64 */
65 pluginName?: string;
66 /**
67 * Plugin data, limited to 65KB.
68 */
69 pluginData?: string;
70};
71
72export type TensorboardsExperimentsRunsTimeSeries = Resource<
73 "GCP.AIPlatform.TensorboardsExperimentsRunsTimeSeries",
74 TensorboardsExperimentsRunsTimeSeriesProps,
75 {
76 /** Full resource name. */
77 name: string;
78 /** Time series id (last path segment). */
79 timeSeriesId: string;
80 /** Parent run resource name. */
81 parent: string;
82 /** Region id. */
83 location: string;
84 /** Project id. */
85 project: string;
86 /** User-facing display name. */
87 displayName: string | undefined;
88 /** User description with the Alchemy ownership prefix stripped. */
89 description: string | undefined;
90 /** Value type. */
91 valueType: string | undefined;
92 /** Plugin name. */
93 pluginName: string | undefined;
94 /** Plugin data. */
95 pluginData: string | undefined;
96 /** RFC3339 creation timestamp. */
97 createTime: string | undefined;
98 /** RFC3339 last-update timestamp. */
99 updateTime: string | undefined;
100 },
101 never,
102 Providers
103>;
104
105/**
106 * A Vertex AI TensorboardTimeSeries of scalar, tensor, or blob values.
107 *
108 * Time series have no labels field — Alchemy stamps ownership into the
109 * description so `read`, `list`, and `pnpm nuke:gcp` can find them.
110 * Parent run, id, value type, and plugin name are immutable.
111 *
112 * ### Creating a Time Series
113 * **Example:** Scalar series under a run
114 * ```typescript
115 * const series = yield* GCP.AIPlatform.TensorboardsExperimentsRunsTimeSeries(
116 * "Loss",
117 * {
118 * parent: run.name,
119 * displayName: "loss",
120 * valueType: "SCALAR",
121 * pluginName: "scalars",
122 * },
123 * );
124 * ```
125 *
126 * @resource
127 * @category AIPlatform
128 */
129export const TensorboardsExperimentsRunsTimeSeries =
130 Resource<TensorboardsExperimentsRunsTimeSeries>(
131 "GCP.AIPlatform.TensorboardsExperimentsRunsTimeSeries",
132 );
133
134/** Alias matching the factory catalog identifier. */
135export const TensorboardsExperimentsRunsTimeSery =
136 TensorboardsExperimentsRunsTimeSeries;
137
138export class TensorboardsExperimentsRunsTimeSeriesNotResolved extends Data.TaggedError(
139 "GCP.AIPlatform.TensorboardsExperimentsRunsTimeSeriesNotResolved",
140)<{
141 name: string;
142}> {}
143
144const DEFAULT_VALUE_TYPE = "SCALAR";
145
146// Time series ids allow only `[a-z0-9]`, unlike the dashed ids
147// `toResourceId` generates.
148const toTimeSeriesId = (
149 id: string,
150 requested: string | undefined,
151 existing: string | undefined,
152) =>
153 requested !== undefined || existing !== undefined
154 ? toResourceId(id, requested, existing)
155 : toResourceId(id, undefined, undefined).pipe(
156 Effect.map((generated) => generated.replace(/[^a-z0-9]/g, "")),
157 );
158
159const resourceName = (parent: string, timeSeriesId: string) =>
160 `${parent}/timeSeries/${timeSeriesId}`;
161
162const toAttrs = (
163 series: aiplatform.GoogleCloudAiplatformV1TensorboardTimeSeries,
164 project: string,
165) => {
166 const name = series.name ?? "";
167 const parsed = parseOwnership(series.description);
168 return {
169 name,
170 timeSeriesId: lastSegment(name),
171 parent: parentOf(name),
172 location: locationOf(name),
173 project,
174 displayName: series.displayName,
175 description: parsed.text,
176 valueType: series.valueType,
177 pluginName: series.pluginName,
178 pluginData: series.pluginData,
179 createTime: series.createTime,
180 updateTime: series.updateTime,
181 };
182};
183
184const getByName = (name: string) =>
185 name.length === 0
186 ? Effect.succeed(undefined)
187 : aiplatform
188 .getProjectsLocationsTensorboardsExperimentsRunsTimeSeries({ name })
189 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
190
191const listTensorboards = (project: string, location: string) =>
192 aiplatform.listProjectsLocationsTensorboards
193 .pages({
194 parent: locationParent(project, location),
195 pageSize: 100,
196 })
197 .pipe(
198 Stream.flatMap((page) => Stream.fromIterable(page.tensorboards ?? [])),
199 Stream.map((board) => board.name ?? ""),
200 Stream.filter((name) => name.length > 0),
201 Stream.runCollect,
202 Effect.map((chunk) => Array.from(chunk)),
203 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
204 );
205
206const listExperiments = (parent: string) =>
207 aiplatform.listProjectsLocationsTensorboardsExperiments
208 .pages({ parent, pageSize: 100 })
209 .pipe(
210 Stream.flatMap((page) =>
211 Stream.fromIterable(page.tensorboardExperiments ?? []),
212 ),
213 Stream.map((experiment) => experiment.name ?? ""),
214 Stream.filter((name) => name.length > 0),
215 Stream.runCollect,
216 Effect.map((chunk) => Array.from(chunk)),
217 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
218 );
219
220const listRuns = (parent: string) =>
221 aiplatform.listProjectsLocationsTensorboardsExperimentsRuns
222 .pages({ parent, pageSize: 100 })
223 .pipe(
224 Stream.flatMap((page) => Stream.fromIterable(page.tensorboardRuns ?? [])),
225 Stream.map((run) => run.name ?? ""),
226 Stream.filter((name) => name.length > 0),
227 Stream.runCollect,
228 Effect.map((chunk) => Array.from(chunk)),
229 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
230 );
231
232const listAtParent = (parent: string, project: string) =>
233 aiplatform.listProjectsLocationsTensorboardsExperimentsRunsTimeSeries
234 .pages({ parent, pageSize: 100 })
235 .pipe(
236 Stream.flatMap((page) =>
237 Stream.fromIterable(page.tensorboardTimeSeries ?? []),
238 ),
239 Stream.filter((series) => hasOwnershipMarker(series.description)),
240 Stream.map((series) => toAttrs(series, project)),
241 Stream.runCollect,
242 Effect.map((chunk) => Array.from(chunk)),
243 Effect.catchTag("NotFound", () => Effect.succeed([])),
244 );
245
246const findOwned = (parent: string, id: string) =>
247 aiplatform.listProjectsLocationsTensorboardsExperimentsRunsTimeSeries
248 .pages({ parent, pageSize: 100 })
249 .pipe(
250 Stream.flatMap((page) =>
251 Stream.fromIterable(page.tensorboardTimeSeries ?? []),
252 ),
253 Stream.filterEffect((series) => ownedByAlchemy(id, series.description)),
254 Stream.runHead,
255 Effect.map(Option.getOrUndefined),
256 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
257 );
258
259export const TensorboardsExperimentsRunsTimeSeriesProvider = () =>
260 Provider.succeed(TensorboardsExperimentsRunsTimeSeries, {
261 stables: [
262 "name",
263 "timeSeriesId",
264 "parent",
265 "location",
266 "project",
267 "valueType",
268 "pluginName",
269 "createTime",
270 ],
271
272 diff: Effect.fn(function* ({ news, olds, output }) {
273 if (!isResolved(news)) return undefined;
274 const previousParent = olds?.parent ?? output?.parent;
275 if (previousParent !== undefined && news.parent !== previousParent) {
276 return { action: "replace" as const, deleteFirst: false };
277 }
278 const previousId = olds?.timeSeriesId ?? output?.timeSeriesId;
279 if (
280 previousId !== undefined &&
281 news.timeSeriesId !== undefined &&
282 news.timeSeriesId !== previousId
283 ) {
284 return { action: "replace" as const, deleteFirst: true };
285 }
286 const previousType = olds?.valueType ?? output?.valueType;
287 const nextType = news.valueType ?? DEFAULT_VALUE_TYPE;
288 if (previousType !== undefined && previousType !== nextType) {
289 return { action: "replace" as const, deleteFirst: true };
290 }
291 return undefined;
292 }),
293
294 read: Effect.fn(function* ({ id, olds, output }) {
295 const env = yield* GcpEnvironment.current;
296 // Vertex AI rejects GETs for ids it did not assign ("Invalid
297 // TensorboardTimeSeries resource name"), so recover by listing.
298 const existing =
299 output?.name !== undefined
300 ? yield* getByName(output.name)
301 : typeof olds?.parent === "string"
302 ? yield* findOwned(olds.parent, id)
303 : undefined;
304 if (existing === undefined) return undefined;
305 const attrs = toAttrs(existing, env.project);
306 return (yield* ownedByAlchemy(id, existing.description))
307 ? attrs
308 : Unowned(attrs);
309 }),
310
311 list: () =>
312 Effect.gen(function* () {
313 const env = yield* GcpEnvironment.current;
314 const boards = (yield* Effect.forEach(
315 listLocations(env.region),
316 (location) => listTensorboards(env.project, location),
317 )).flat();
318 const experiments = (yield* Effect.forEach(boards, listExperiments, {
319 concurrency: 4,
320 })).flat();
321 const runs = (yield* Effect.forEach(experiments, listRuns, {
322 concurrency: 4,
323 })).flat();
324 const pages = yield* Effect.forEach(
325 runs,
326 (parent) => listAtParent(parent, env.project),
327 { concurrency: 4 },
328 );
329 return pages.flat();
330 }),
331
332 reconcile: Effect.fn(function* ({ id, news, output }) {
333 const env = yield* GcpEnvironment.current;
334 const timeSeriesId = yield* toTimeSeriesId(
335 id,
336 news.timeSeriesId,
337 output?.timeSeriesId,
338 );
339 const name = resourceName(news.parent, timeSeriesId);
340 const ownership = yield* createInternalLabels(id);
341 const desiredDescription = encodeOwnership(ownership, news.description);
342 const displayName = news.displayName ?? timeSeriesId;
343 const valueType = news.valueType ?? DEFAULT_VALUE_TYPE;
344
345 let current =
346 (output?.name !== undefined
347 ? yield* getByName(output.name)
348 : undefined) ?? (yield* findOwned(news.parent, id));
349
350 if (current === undefined) {
351 const created = yield* aiplatform
352 .createProjectsLocationsTensorboardsExperimentsRunsTimeSeries({
353 parent: news.parent,
354 tensorboardTimeSeriesId: news.timeSeriesId,
355 body: {
356 displayName,
357 description: desiredDescription,
358 valueType,
359 pluginName: news.pluginName,
360 pluginData: news.pluginData,
361 },
362 })
363 .pipe(Effect.catchTag("Conflict", () => findOwned(news.parent, id)));
364 current = created ?? undefined;
365 }
366
367 if (current === undefined) {
368 return yield* new TensorboardsExperimentsRunsTimeSeriesNotResolved({
369 name,
370 });
371 }
372
373 const displayChanged = (current.displayName ?? "") !== displayName;
374 const descriptionChanged =
375 (current.description ?? "") !== desiredDescription;
376 const pluginDataChanged =
377 (current.pluginData ?? "") !== (news.pluginData ?? "");
378
379 if (displayChanged || descriptionChanged || pluginDataChanged) {
380 current =
381 yield* aiplatform.patchProjectsLocationsTensorboardsExperimentsRunsTimeSeries(
382 {
383 name,
384 updateMask: [
385 displayChanged ? "displayName" : undefined,
386 descriptionChanged ? "description" : undefined,
387 pluginDataChanged ? "pluginData" : undefined,
388 ]
389 .filter((field): field is string => field !== undefined)
390 .join(","),
391 body: {
392 name,
393 displayName,
394 description: desiredDescription,
395 pluginData: news.pluginData,
396 },
397 },
398 );
399 }
400
401 return toAttrs(current, env.project);
402 }),
403
404 delete: Effect.fn(function* ({ output }) {
405 const existing = yield* getByName(output.name);
406 if (existing === undefined) return;
407 const operation = yield* aiplatform
408 .deleteProjectsLocationsTensorboardsExperimentsRunsTimeSeries({
409 name: output.name,
410 })
411 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
412 if (operation !== undefined) {
413 yield* waitForOperation(operation, { notFoundOk: true });
414 }
415 }),
416 });