unfazed 0.6.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/unfazed.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { UnfazedClient } from "./client.js";
2
- import { DEPLOYMENT_NOT_FOUND, UnfazedConflictError, UnfazedDeployError, UnfazedGraphQLError, UnfazedNotFoundError, UnfazedRunError, WORKSPACE_NOT_FOUND, isDuplicateWorkspaceName, isNotFoundSentinel as isNotFound, looksLikeCASConflict, } from "./errors.js";
2
+ import { DEPLOYMENT_NOT_FOUND, UnfazedConflictError, UnfazedDeployError, UnfazedError, UnfazedGraphQLError, UnfazedNotFoundError, UnfazedRunError, UnfazedTimeoutError, UnfazedUnconfirmedAppsError, UnfazedWriteError, WORKSPACE_NOT_FOUND, isAppNotFound, isDuplicateWorkspaceName, isNotFoundSentinel as isNotFound, isUnconfirmedAppsRefusal, isTooManyWaits, isUnsupportedField, looksLikeCASConflict, } from "./errors.js";
3
3
  import * as ops from "./operations.js";
4
4
  import { pollWithBackoff, resolveWaitOptions } from "./wait.js";
5
5
  /**
@@ -44,41 +44,103 @@ export class Unfazed {
44
44
  return data.workspaces;
45
45
  }
46
46
  /**
47
- * Deletes a workspace and everything anchored to it — deployments, env, and
48
- * a BOX's persistent data disk. Not recoverable.
47
+ * Deletes a workspace — its files, env and secrets, and a BOX's persistent
48
+ * data disk — and, with `withDeployments`, every app it serves.
49
49
  *
50
- * **The cascade is opt-out, not opt-in.** The server refuses to delete a
51
- * workspace that has ANY deployment history — including FAILED records, and
52
- * including deployments whose alias was long since taken over by something
53
- * else — unless the mutation carries `withDeployments`. Since almost every
54
- * workspace an agent creates gets deployed at least once, defaulting to
55
- * `false` would make this method fail on nearly every real workspace, so it
56
- * defaults to **`true`**: the behaviour this docstring has always promised.
57
- *
58
- * Pass `withDeployments: false` for the strict variant, which refuses rather
59
- * than cascading and is the safer choice when you are deleting a workspace
60
- * you did not create:
50
+ * A workspace can serve several apps (each `unfazed.json` `apps[]` entry, or
51
+ * several aliases deployed into it), so deleting one with its deployments is
52
+ * never implicit: **name exactly the apps it takes offline** in
53
+ * `confirmAliases`. The server compares them with the workspace's live apps
54
+ * and refuses, deleting nothing, on a missing or extra alias; the refusal
55
+ * ({@link UnfazedUnconfirmedAppsError}) lists every live app with its
56
+ * domains. The SDK never fills the list in: look it up deliberately with
57
+ * {@link liveApps} and decide. To delete one app and keep the rest, use
58
+ * {@link deleteApp}.
61
59
  *
62
60
  * ```ts
63
- * await unfazed.deleteWorkspace(ws.id); // cascade
64
- * await unfazed.deleteWorkspace(ws.id, { withDeployments: false }); // refuse if deployed
65
- * await unfazed.deleteWorkspace(id, { ignoreNotFound: true }); // false, not a throw
61
+ * await unfazed.deleteWorkspace(ws.id); // only when no deployment is left
62
+ * await unfazed.deleteWorkspace(ws.id, { withDeployments: true, confirmAliases: ["web", "api"] });
63
+ * await unfazed.deleteWorkspace(ws.id, { withDeployments: true, confirmAliases: [] }); // serves no app
64
+ * await unfazed.deleteWorkspace(id, { ignoreNotFound: true }); // false, not a throw
66
65
  * ```
67
66
  *
67
+ * Without `withDeployments` the server refuses while ANY deployment record
68
+ * is left — FAILED ones included — with `still has deployments; delete them
69
+ * first`. A workspace whose deploys all failed serves no app: delete it with
70
+ * `withDeployments: true, confirmAliases: []`.
71
+ *
72
+ * What is deleted disappears at once — gone from your views, its names and
73
+ * aliases free, its VMs stopped — but the platform keeps it for 30 days:
74
+ * within that time support (the platform operator) can restore it exactly
75
+ * as it was. You cannot restore it yourself; ask support. After 30 days it
76
+ * is erased for good.
77
+ *
68
78
  * With `ignoreNotFound` a workspace that is absent (or belongs to someone
69
79
  * else — the server returns one indistinguishable sentinel for both, on
70
80
  * purpose) resolves to `false` instead of throwing, which makes teardown in
71
81
  * a `finally` block idempotent.
72
82
  */
73
83
  async deleteWorkspace(workspaceId, options = {}) {
74
- const withDeployments = options.withDeployments ?? true;
84
+ const withDeployments = options.withDeployments === true;
85
+ const confirmAliases = options.confirmAliases;
86
+ // The types already require both together; these guard plain JavaScript
87
+ // callers, before anything is sent.
88
+ if (withDeployments && !Array.isArray(confirmAliases)) {
89
+ throw new UnfazedError(`unfazed: nothing was deleted: deleteWorkspace("${workspaceId}", { withDeployments: true }) ` +
90
+ "takes every app the workspace serves offline, so it needs confirmAliases naming exactly " +
91
+ `those apps ([] when it serves none). Look them up with unfazed.liveApps("${workspaceId}"); ` +
92
+ "to delete one app, use unfazed.deleteApp.");
93
+ }
94
+ if (!withDeployments && confirmAliases !== undefined) {
95
+ throw new UnfazedError("unfazed: nothing was deleted: confirmAliases applies only with withDeployments: true.");
96
+ }
75
97
  try {
76
- const data = await this.client.mutate(ops.DeleteWorkspaceDocument, { workspaceID: workspaceId, withDeployments }, options);
98
+ const data = await this.client.mutate(ops.DeleteWorkspaceDocument, withDeployments
99
+ ? { workspaceID: workspaceId, withDeployments, confirmAliases: [...confirmAliases] }
100
+ : { workspaceID: workspaceId, withDeployments }, options);
77
101
  return data.deleteWorkspace.ok;
78
102
  }
79
103
  catch (e) {
80
104
  if (options.ignoreNotFound && isNotFound(e, WORKSPACE_NOT_FOUND))
81
105
  return false;
106
+ if (isUnconfirmedAppsRefusal(e)) {
107
+ throw new UnfazedUnconfirmedAppsError(workspaceId, [...(confirmAliases ?? [])], e.errors, e.data);
108
+ }
109
+ throw e;
110
+ }
111
+ }
112
+ /**
113
+ * The apps a workspace serves right now, by alias, with the deployment
114
+ * serving each and its custom domains: everything
115
+ * `deleteWorkspace(id, { withDeployments: true, … })` takes offline, and the
116
+ * exact list its `confirmAliases` must name.
117
+ *
118
+ * It is a lookup for you to read, not something to pipe into a delete
119
+ * unseen: check that the apps are the ones you mean to take down.
120
+ *
121
+ * ```ts
122
+ * const live = await unfazed.liveApps(ws.id); // [{ alias: "web", deploymentID, domains: [] }, …]
123
+ * ```
124
+ *
125
+ * Throws {@link UnfazedNotFoundError} when the workspace is absent or not
126
+ * visible to this token.
127
+ */
128
+ async liveApps(workspaceId, options) {
129
+ const data = await this.client.query(ops.WorkspaceLiveAppsDocument, { id: workspaceId }, options);
130
+ if (!data.workspace) {
131
+ throw new UnfazedNotFoundError(`unfazed: workspace ${workspaceId} not found`);
132
+ }
133
+ return data.workspace.liveApps;
134
+ }
135
+ async deleteApp(app, options = {}) {
136
+ try {
137
+ const data = await this.client.mutate(ops.DeleteAppDocument, { workspaceID: app.workspaceId, alias: app.alias }, options);
138
+ return data.deleteApp;
139
+ }
140
+ catch (e) {
141
+ if (options.ignoreNotFound && (isNotFound(e, WORKSPACE_NOT_FOUND) || isAppNotFound(e, app.alias))) {
142
+ return null;
143
+ }
82
144
  throw e;
83
145
  }
84
146
  }
@@ -120,48 +182,143 @@ export class Unfazed {
120
182
  }
121
183
  /* ------------------------------------------------------------------- files */
122
184
  /**
123
- * Writes many files in as few requests as possible.
124
- *
125
- * The API is one-file-per-mutation, so writing a 12-file app naively costs 12
126
- * round trips. This batches them into a single GraphQL document with one
127
- * ALIASED root field per file (`f0: writeWorkspaceTextFile(...)`,
128
- * `f1: ...`), which the server resolves in one request.
185
+ * Writes many files in ONE request — and, on an app with platform git, ONE
186
+ * commit — exactly as MCP `write_workspace_files` does.
129
187
  *
130
188
  * ```ts
131
- * await unfazed.writeFiles(ws.id, {
189
+ * const out = await unfazed.writeFiles(ws.id, {
132
190
  * "server.ts": serverCode,
133
- * "unfazed.json": JSON.stringify({ entry: "server.ts" }),
191
+ * "package.json": JSON.stringify(pkg),
134
192
  * "public/logo.png": { bytes: pngBytes },
135
- * });
193
+ * }, { commitMessage: "Add the server" });
194
+ * out.commit; // { sha, committed, waiting, conflicts, writeSHA, … } or null
136
195
  * ```
137
196
  *
138
- * Batches are NOT atomic: GraphQL resolves each aliased field independently,
139
- * so a partial failure leaves earlier writes committed. The thrown
140
- * {@link UnfazedGraphQLError} carries `data` with whichever aliases
141
- * succeeded, and `error.path` identifies the ones that did not.
197
+ * A call is not transactional: files are written in order, each atomically,
198
+ * and a file's failure does not undo the others. When any file fails this
199
+ * throws {@link UnfazedWriteError} (its `result` lists what was written and
200
+ * `failed` what was not; retry only those), unless `allowPartial`. A
201
+ * repeated path keeps its last content.
202
+ *
203
+ * On an app with platform git the commit is made before any file is
204
+ * written: if it fails, the request fails and nothing is written. `commit`
205
+ * may come back `waiting` — the files are saved and the commit lands in
206
+ * order; pass `waitForCommit: true` (or call {@link waitForCommit}) to
207
+ * resolve only once it has. See {@link FileCommit} for saved, committed and
208
+ * deployed.
209
+ *
210
+ * One request carries up to 100 files and 32 MiB; a bigger map is split
211
+ * into several requests, each its own commit (`commits`). On a platform
212
+ * older than the batch write the SDK falls back to one mutation per file
213
+ * (one commit each) and reports `commit: null`.
142
214
  */
143
215
  async writeFiles(workspaceId, files, options = {}) {
144
216
  const entries = Object.entries(files).map(([path, content]) => normalizeFile(path, content));
217
+ const result = {
218
+ workspaceId,
219
+ files: [],
220
+ written: 0,
221
+ failed: 0,
222
+ totalBytes: 0,
223
+ commit: null,
224
+ commits: [],
225
+ };
145
226
  if (entries.length === 0)
146
- return [];
147
- const batchSize = options.batchSize ?? 20;
148
- const maxBatchBytes = options.maxBatchBytes ?? 4 * 1024 * 1024;
149
- const written = [];
227
+ return result;
228
+ const batchSize = Math.min(options.batchSize ?? MAX_BATCH_FILES, MAX_BATCH_FILES);
229
+ const maxBatchBytes = options.maxBatchBytes ?? MAX_BATCH_BYTES;
230
+ const requestOptions = options.signal ? { signal: options.signal } : {};
150
231
  for (const batch of chunkFiles(entries, batchSize, maxBatchBytes)) {
151
- written.push(...(await this.#writeBatch(workspaceId, batch, options.signal)));
232
+ const written = this.#legacyWrites
233
+ ? await this.#writeBatchLegacy(workspaceId, batch, requestOptions)
234
+ : await this.#writeBatch(workspaceId, batch, options.commitMessage, requestOptions);
235
+ result.files.push(...written.files);
236
+ result.written += written.written;
237
+ result.failed += written.failed;
238
+ result.totalBytes += written.totalBytes;
239
+ if (written.commit) {
240
+ result.commits.push(written.commit);
241
+ result.commit = written.commit;
242
+ }
243
+ }
244
+ // Only a waiting commit lands by itself; a paused one waits for the
245
+ // operator's recovery, which no client wait should block on.
246
+ if (options.waitForCommit && result.commits.some((c) => c.waiting)) {
247
+ const waitOptions = options.waitForCommit === true ? {} : options.waitForCommit;
248
+ result.landed = await this.waitForCommit(workspaceId, {
249
+ ...waitOptions,
250
+ ...(options.signal ? { signal: options.signal } : {}),
251
+ });
252
+ }
253
+ if (result.failed > 0 && !options.allowPartial) {
254
+ throw new UnfazedWriteError(result, result.files.filter((f) => !f.ok).map((f) => ({ path: f.path, error: f.error ?? "not written" })));
152
255
  }
153
- return written;
256
+ return result;
154
257
  }
155
- /** Writes one text file. Prefer {@link writeFiles} for more than one. */
156
- async writeFile(workspaceId, path, text, options) {
157
- const data = await this.client.mutate(ops.WriteTextFileDocument, { workspaceID: workspaceId, path, text }, options);
158
- return data.writeWorkspaceTextFile.file;
258
+ /**
259
+ * Writes one text file: {@link writeFiles} with one entry, so it is one
260
+ * commit with `commitMessage` on an app with platform git, and the answer
261
+ * carries that commit. Throws {@link UnfazedWriteError} when it is refused.
262
+ */
263
+ async writeFile(workspaceId, path, text, options = {}) {
264
+ return this.#writeOne(workspaceId, path, text, options);
265
+ }
266
+ /** Writes one binary file (raw bytes or base64), like {@link writeFile}. */
267
+ async writeBinaryFile(workspaceId, path, content, options = {}) {
268
+ const file = content instanceof Uint8Array || content instanceof ArrayBuffer ? { bytes: content } : content;
269
+ return this.#writeOne(workspaceId, path, file, options);
159
270
  }
160
- /** Writes one binary file. Accepts raw bytes or a base64 string. */
161
- async writeBinaryFile(workspaceId, path, content, options) {
162
- const contentBase64 = "base64" in content ? content.base64 : toBase64(content);
163
- const data = await this.client.mutate(ops.WriteBase64FileDocument, { workspaceID: workspaceId, path, contentBase64 }, options);
164
- return data.writeWorkspaceBase64File.file;
271
+ async #writeOne(workspaceId, path, content, options) {
272
+ const out = await this.writeFiles(workspaceId, { [path]: content }, options);
273
+ const file = out.files[0]?.file;
274
+ if (!file)
275
+ throw new UnfazedError(`unfazed: ${path} was not written`);
276
+ return { ...file, commit: out.commit };
277
+ }
278
+ /**
279
+ * Waits until no file write of the app waits for its commit: platform git
280
+ * commits a write at once, but when the git server is slow the write is
281
+ * saved and its commit `waiting`, landing in order. Resolves with the
282
+ * branch head then — every write made before the call is in its history.
283
+ * An app without platform git resolves at once (`head: null`).
284
+ *
285
+ * Default timeout 2 minutes; throws {@link UnfazedTimeoutError} past it
286
+ * (the writes stay saved, and still land).
287
+ */
288
+ async waitForCommit(workspaceId, waitOptions = {}) {
289
+ const resolved = resolveWaitOptions({
290
+ timeoutMs: 120_000,
291
+ initialIntervalMs: 250,
292
+ maxIntervalMs: 2_000,
293
+ ...waitOptions,
294
+ });
295
+ const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
296
+ const started = resolved.now();
297
+ let waiting = 0;
298
+ return pollWithBackoff(async () => {
299
+ let page;
300
+ try {
301
+ page = (await this.client.query(ops.AppCommitsWaitingDocument, { workspaceID: workspaceId }, requestOptions))
302
+ .appCommits;
303
+ }
304
+ catch (e) {
305
+ if (!(e instanceof UnfazedGraphQLError))
306
+ throw e;
307
+ // The history can be unreadable while writes still wait: keep waiting.
308
+ if (e.errors.some((x) => /waiting to be committed/i.test(x.message)))
309
+ return { done: false };
310
+ // An app without platform git has no commits to wait for.
311
+ if (e.errors.some((x) => NO_PLATFORM_GIT.test(x.message))) {
312
+ return { done: true, value: { branch: "", head: null, waitedMs: resolved.now() - started } };
313
+ }
314
+ throw e;
315
+ }
316
+ waiting = page.waitingCommits;
317
+ if (page.waitingCommits > 0)
318
+ return { done: false };
319
+ return { done: true, value: { branch: page.branch, head: page.head ?? null, waitedMs: resolved.now() - started } };
320
+ }, resolved, () => new UnfazedTimeoutError(`unfazed: ${waiting} write(s) of workspace ${workspaceId} still wait for their commit after ${resolved.timeoutMs}ms; ` +
321
+ "they are saved and land in order"));
165
322
  }
166
323
  /** Lists workspace files (paginated). */
167
324
  async listFiles(workspaceId, input = {}, options) {
@@ -266,29 +423,193 @@ export class Unfazed {
266
423
  }
267
424
  async #deploy(input, options) {
268
425
  const requestOptions = options.signal ? { signal: options.signal } : {};
426
+ const now = options.waitOptions?.now ?? (() => Date.now());
427
+ const sent = now();
269
428
  const data = await this.client.mutate(ops.CreateDeploymentDocument, { input }, requestOptions);
270
429
  const deployment = data.createDeployment.deployment;
271
430
  if (!options.wait)
272
431
  return deployment;
273
- return this.waitForDeployment(deployment.id, {
432
+ const created = now();
433
+ const waited = await this.#waitTerminal(deployment.id, {
274
434
  ...options.waitOptions,
275
435
  ...(options.signal ? { signal: options.signal } : {}),
276
436
  });
437
+ const seen = now();
438
+ const timings = {
439
+ client: {
440
+ createMs: created - sent,
441
+ waitMs: seen - created,
442
+ totalMs: seen - sent,
443
+ waitRequests: waited.requests,
444
+ waitMode: waited.mode,
445
+ },
446
+ platform: null,
447
+ };
448
+ if (options.timings !== false) {
449
+ timings.platform = await this.#readyTimings(deployment.id, options.waitOptions, requestOptions);
450
+ }
451
+ return { ...waited.deployment, timings };
277
452
  }
278
453
  /**
279
- * Polls a deployment until it is READY, throwing on FAILED or timeout.
454
+ * Waits until a deployment is READY, throwing {@link UnfazedDeployError} on
455
+ * FAILED or timeout (default 10 minutes).
456
+ *
457
+ * It waits SERVER-SIDE (MCP `wait_for_deployment`): each request blocks on
458
+ * the control plane until the deployment leaves BUILDING — woken by the
459
+ * deploy pipeline itself — or 45 s pass, and comes back with the full
460
+ * deployment; so READY reaches you one round trip after it happens, not at
461
+ * the next poll. A NEW app that starts directly on its server is READY
462
+ * before it serves there; the wait covers that landing too. On a platform
463
+ * without the server-side wait, or when the platform refuses one because
464
+ * the account already holds its cap of waits in progress (16), it polls
465
+ * instead (`waitOptions.mode: "poll"` forces that), every 0.5 s backing
466
+ * off to 3 s by default.
280
467
  *
281
- * BUILDING is the only non-terminal status; READY and FAILED are terminal.
282
- * The poll query selects six fields rather than the full deployment, because
283
- * this runs every few seconds for potentially minutes — then the READY
284
- * deployment is re-read in full so callers get the same shape `deployService`
285
- * returns without `wait`.
468
+ * Pass a {@link CommitDeploymentTarget} instead of an id to wait for the
469
+ * deploy a git import makes of a commit — a restore, a push or a sync
470
+ * deploys before any id exists. The wait covers the import too, and
471
+ * {@link UnfazedDeployError} carries the import's error when it failed on
472
+ * the commit.
473
+ *
474
+ * ```ts
475
+ * const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha });
476
+ * await unfazed.waitForDeployment({
477
+ * workspaceId: back.workspaceID, gitCommit: back.sha, alias: back.deployAlias, after: back.waitAfter,
478
+ * });
479
+ * ```
286
480
  */
287
- async waitForDeployment(deploymentId, waitOptions = {}) {
481
+ async waitForDeployment(target, waitOptions = {}) {
482
+ if (typeof target !== "string")
483
+ return (await this.#commitWait(target, waitOptions)).deployment;
484
+ return (await this.#waitTerminal(target, waitOptions)).deployment;
485
+ }
486
+ async #waitTerminal(deploymentId, waitOptions) {
487
+ const now = waitOptions.now ?? (() => Date.now());
488
+ const started = now();
489
+ if ((waitOptions.mode ?? "server") === "server" && !this.#noServerWait) {
490
+ try {
491
+ return await this.#serverWait(deploymentId, waitOptions);
492
+ }
493
+ catch (e) {
494
+ if (isUnsupportedField(e, "waitForDeployment")) {
495
+ // An older platform: poll, and do not ask again on this instance.
496
+ this.#noServerWait = true;
497
+ }
498
+ else if (!isTooManyWaits(e)) {
499
+ throw e;
500
+ }
501
+ // Past the platform's cap on waits in progress (many parallel waits
502
+ // of one account): this wait polls instead, for what is left of its
503
+ // budget.
504
+ }
505
+ }
506
+ const timeoutMs = Math.max(0, (waitOptions.timeoutMs ?? 600_000) - (now() - started));
507
+ return this.#pollWait(deploymentId, { ...waitOptions, timeoutMs });
508
+ }
509
+ async #serverWait(deploymentId, waitOptions) {
288
510
  const resolved = resolveWaitOptions(waitOptions);
511
+ const deadline = resolved.now() + resolved.timeoutMs;
512
+ let last = null;
513
+ let landing = false;
514
+ for (let requests = 1;; requests++) {
515
+ if (resolved.signal?.aborted)
516
+ throw new UnfazedTimeoutError("unfazed: wait aborted");
517
+ const remaining = deadline - resolved.now();
518
+ if (requests > 1 && remaining <= 0) {
519
+ throw new UnfazedDeployError("TIMEOUT", deploymentId, last, `unfazed: deployment ${deploymentId} still ${last?.status ?? "BUILDING"}` +
520
+ `${landing ? " (READY, still landing on its server)" : ""} after ${resolved.timeoutMs}ms`);
521
+ }
522
+ const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
523
+ let answer;
524
+ try {
525
+ answer = (await this.client.query(ops.WaitForDeploymentDocument, { id: deploymentId, timeoutSeconds: seconds },
526
+ // The request is a long poll: give it the wait plus a round trip,
527
+ // whatever the client's default per-request timeout is.
528
+ { ...(resolved.signal ? { signal: resolved.signal } : {}), timeoutMs: (seconds + 30) * 1000 })).waitForDeployment;
529
+ }
530
+ catch (e) {
531
+ if (isNotFound(e, DEPLOYMENT_NOT_FOUND)) {
532
+ throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
533
+ }
534
+ throw e;
535
+ }
536
+ // By id the platform always answers the deployment; null is only for a
537
+ // wait by commit before the import made it.
538
+ const d = answer.deployment;
539
+ if (!d)
540
+ throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
541
+ last = d;
542
+ if (answer.timedOut) {
543
+ landing = d.status === "READY";
544
+ continue;
545
+ }
546
+ if (d.status === "READY")
547
+ return { deployment: d, requests, mode: "server" };
548
+ throw new UnfazedDeployError("FAILED", deploymentId, d, `unfazed: deployment ${deploymentId} ${d.status}${d.error ? `: ${d.error}` : ""}`);
549
+ }
550
+ }
551
+ /**
552
+ * The server-side wait by commit: for the import to make the commit's
553
+ * deployment, then for it, one budget for both. There is nothing cheaper to
554
+ * poll, so a refusal past the platform's cap on waits in progress backs off
555
+ * (0.5 s up to 3 s) and asks again.
556
+ */
557
+ async #commitWait(target, waitOptions) {
558
+ const resolved = resolveWaitOptions(waitOptions);
559
+ const deadline = resolved.now() + resolved.timeoutMs;
560
+ const commit = target.gitCommit.slice(0, 7);
561
+ const what = `the deploy of ${commit}${target.alias ? ` to ${target.alias}` : ""}`;
562
+ let last = null;
563
+ let status = "IMPORTING";
564
+ let backoff = 500;
565
+ for (let requests = 1;; requests++) {
566
+ if (resolved.signal?.aborted)
567
+ throw new UnfazedTimeoutError("unfazed: wait aborted");
568
+ const remaining = deadline - resolved.now();
569
+ if (requests > 1 && remaining <= 0) {
570
+ throw new UnfazedDeployError("TIMEOUT", last?.id ?? "", last, `unfazed: ${what} still ${status} after ${resolved.timeoutMs}ms`);
571
+ }
572
+ const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
573
+ let answer;
574
+ try {
575
+ answer = (await this.client.query(ops.WaitForCommitDeploymentDocument, {
576
+ workspaceID: target.workspaceId,
577
+ gitCommit: target.gitCommit,
578
+ alias: target.alias ?? null,
579
+ after: target.after ?? null,
580
+ timeoutSeconds: seconds,
581
+ }, { ...(resolved.signal ? { signal: resolved.signal } : {}), timeoutMs: (seconds + 30) * 1000 })).waitForDeployment;
582
+ }
583
+ catch (e) {
584
+ if (!isTooManyWaits(e))
585
+ throw e;
586
+ await resolved.sleep(Math.max(0, Math.min(backoff, deadline - resolved.now())), resolved.signal);
587
+ backoff = Math.min(backoff * 1.5, 3_000);
588
+ continue;
589
+ }
590
+ const d = answer.deployment;
591
+ status = answer.status;
592
+ if (d)
593
+ last = d;
594
+ if (answer.timedOut)
595
+ continue;
596
+ if (!d) {
597
+ // Not timed out and no deployment: the import failed on the commit
598
+ // and nothing imports any more, so no deployment of it is coming.
599
+ throw new UnfazedDeployError("FAILED", "", null, `unfazed: the import of ${commit} failed${answer.importError ? `: ${answer.importError}` : ""}`);
600
+ }
601
+ if (d.status === "READY")
602
+ return { deployment: d, requests };
603
+ throw new UnfazedDeployError("FAILED", d.id, d, `unfazed: deployment ${d.id} (${what}) ${d.status}${d.error ? `: ${d.error}` : ""}`);
604
+ }
605
+ }
606
+ async #pollWait(deploymentId, waitOptions) {
607
+ const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
289
608
  const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
290
609
  let last = null;
610
+ let requests = 0;
291
611
  await pollWithBackoff(async () => {
612
+ requests++;
292
613
  const data = await this.client.query(ops.PollDeploymentDocument, { id: deploymentId }, requestOptions);
293
614
  const d = data.deployment;
294
615
  if (!d) {
@@ -302,10 +623,43 @@ export class Unfazed {
302
623
  }
303
624
  return { done: false };
304
625
  }, resolved, () => new UnfazedDeployError("TIMEOUT", deploymentId, last, `unfazed: deployment ${deploymentId} still ${last?.status ?? "BUILDING"} after ${resolved.timeoutMs}ms`));
626
+ // The poll query is narrow; re-read the READY deployment in full so the
627
+ // answer has the shape deployService returns without `wait`.
305
628
  const full = await this.getDeployment(deploymentId, requestOptions);
306
629
  if (!full)
307
630
  throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
308
- return full;
631
+ return { deployment: full, requests: requests + 1, mode: "poll" };
632
+ }
633
+ /**
634
+ * The platform's measured phases of a deploy ({@link PlatformTimings}), as
635
+ * it parses them from all of the deployment's timing events
636
+ * (`Deployment.timings`). Null when it recorded none (a deploy that is
637
+ * still building, or one from before timings existed), or for a deployment
638
+ * you cannot see.
639
+ */
640
+ async getDeploymentTimings(deploymentId, options) {
641
+ const data = await this.client.query(ops.DeploymentTimingsDocument, { id: deploymentId }, options);
642
+ const timings = data.deployment?.timings;
643
+ return timings ? toPlatformTimings(timings) : null;
644
+ }
645
+ /**
646
+ * The timings of a deploy that just went READY. `deployment.phases` is
647
+ * written right after the READY transition, so a read that races it is
648
+ * retried once.
649
+ */
650
+ async #readyTimings(deploymentId, waitOptions, requestOptions) {
651
+ try {
652
+ const first = await this.getDeploymentTimings(deploymentId, requestOptions);
653
+ if (first?.totalMs != null)
654
+ return first;
655
+ const sleep = waitOptions?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
656
+ await sleep(250, requestOptions.signal);
657
+ return (await this.getDeploymentTimings(deploymentId, requestOptions)) ?? first;
658
+ }
659
+ catch {
660
+ // Timings are a diagnostic: never fail a deploy that went READY.
661
+ return null;
662
+ }
309
663
  }
310
664
  /** Reads one deployment by id. Null when absent. */
311
665
  async getDeployment(deploymentId, options) {
@@ -340,10 +694,25 @@ export class Unfazed {
340
694
  const data = await this.client.query(ops.ServiceLogsDocument, { deploymentID: deploymentId, tailBytes: input.tailBytes ?? null }, options);
341
695
  return data.serviceLogs;
342
696
  }
343
- /** Deployment lifecycle events — build, boot, probe, cron, restarts. */
697
+ /**
698
+ * Deployment lifecycle events — build, boot, probe, cron, restarts — the
699
+ * most recent `limit` (default 20), oldest first.
700
+ *
701
+ * `type` is the event's own name, as MCP reports it:
702
+ * `"deployment.build_finished"`, `"deployment.phases"`,
703
+ * `"service.vm_started"`, `"service.crashed"`. (`typeEnum` keeps the GraphQL
704
+ * enum, which reads `UNKNOWN` for names it does not list.) Timing events
705
+ * carry their phases in `metadata`; {@link getDeploymentTimings} turns them
706
+ * into numbers.
707
+ *
708
+ * ```ts
709
+ * const events = await unfazed.getEvents(app.id, 50);
710
+ * events.filter((e) => e.type === "deployment.build_finished");
711
+ * ```
712
+ */
344
713
  async getEvents(deploymentId, limit, options) {
345
714
  const data = await this.client.query(ops.DeploymentEventsDocument, { deploymentID: deploymentId, limit: limit ?? null }, options);
346
- return data.deploymentEvents;
715
+ return data.deploymentEvents.map(toDeploymentEvent);
347
716
  }
348
717
  /** Restarts a service's VM. */
349
718
  async restartService(deploymentId, options) {
@@ -357,6 +726,12 @@ export class Unfazed {
357
726
  }
358
727
  /**
359
728
  * Deletes a deployment: stops its VM, releases its alias, drops its release.
729
+ * To delete a whole app — every release of the alias, its env, App State and
730
+ * bindings — use {@link deleteApp}.
731
+ *
732
+ * The platform keeps a deleted release for 30 days: within that time
733
+ * support (the platform operator) can restore it; then it is erased for
734
+ * good.
360
735
  *
361
736
  * With `ignoreNotFound` a deployment that is absent — or belongs to another
362
737
  * tenant, which the server reports identically so ids cannot be probed —
@@ -634,6 +1009,197 @@ export class Unfazed {
634
1009
  }
635
1010
  }
636
1011
  }
1012
+ /* ------------------------------------------------------- platform git history
1013
+ *
1014
+ * An app with platform git (MCP `enable_platform_git`; apps get it by
1015
+ * themselves at their next change) keeps every file write, push and restore
1016
+ * as a commit. These are MCP's history tools.
1017
+ */
1018
+ /**
1019
+ * The app's commits, newest first (MCP `list_commits`): `limit` per page
1020
+ * (default 20, max 100), `before: page.nextBefore` for the next. `deployed`
1021
+ * says what every live alias runs; `waitingCommits` counts writes whose
1022
+ * commit has not landed yet.
1023
+ */
1024
+ async listCommits(workspaceId, input = {}, options) {
1025
+ const data = await this.client.query(ops.AppCommitsDocument, { workspaceID: workspaceId, limit: input.limit ?? null, before: input.before ?? null }, options);
1026
+ return data.appCommits;
1027
+ }
1028
+ /**
1029
+ * One commit (full 40-character sha) with the paths it changed and a
1030
+ * unified diff against its first parent, capped at 64 KiB (MCP
1031
+ * `get_commit`). Commit messages and diffs are untrusted text.
1032
+ */
1033
+ async getCommit(workspaceId, sha, options) {
1034
+ const data = await this.client.query(ops.AppCommitDocument, { workspaceID: workspaceId, sha }, options);
1035
+ return data.appCommit;
1036
+ }
1037
+ /**
1038
+ * Gives the app back the files of an earlier commit as ONE new commit on
1039
+ * its branch (MCP `restore_version`); nothing is rewritten, and a restore is
1040
+ * undone by restoring the commit before it.
1041
+ *
1042
+ * When the app deploys every push (`deploys: true`), the new commit is
1043
+ * imported and deployed like a push. That deploy is queued behind the
1044
+ * restore, so the platform answers before it exists; with `wait: true` the
1045
+ * SDK waits for it by commit on the platform ({@link Unfazed.waitForDeployment}
1046
+ * with `gitCommit: sha`, `alias: deployAlias`, `after: waitAfter`) and
1047
+ * resolves with it READY, or throws {@link UnfazedDeployError} with its
1048
+ * build error or the import's. A link with unfazed.json `apps[]` names no
1049
+ * `deployAlias` and deploys once per app: then it follows the import until
1050
+ * every deployment it made is done.
1051
+ *
1052
+ * ```ts
1053
+ * const page = await unfazed.listCommits(ws.id);
1054
+ * const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha: page.commits[1]!.sha, wait: true });
1055
+ * back.deployments?.[0]?.url;
1056
+ * ```
1057
+ */
1058
+ async restoreVersion(options) {
1059
+ const requestOptions = options.signal ? { signal: options.signal } : {};
1060
+ const data = await this.client.mutate(ops.RestoreVersionDocument, {
1061
+ workspaceID: options.workspaceId,
1062
+ sha: options.sha,
1063
+ expectedHead: options.expectedHead ?? null,
1064
+ message: options.commitMessage ?? null,
1065
+ }, requestOptions);
1066
+ const restored = data.restoreAppVersion;
1067
+ if (!options.wait || !restored.deploys)
1068
+ return restored;
1069
+ const waitOptions = { ...options.waitOptions, ...(options.signal ? { signal: options.signal } : {}) };
1070
+ if (restored.deployAlias) {
1071
+ const { deployment } = await this.#commitWait({ workspaceId: restored.workspaceID, gitCommit: restored.sha, alias: restored.deployAlias, after: restored.waitAfter }, waitOptions);
1072
+ return { ...restored, deployments: [deployment] };
1073
+ }
1074
+ return { ...restored, deployments: await this.#waitForImport(restored.workspaceID, restored.sha, waitOptions) };
1075
+ }
1076
+ /**
1077
+ * Waits until the app's git link has imported `sha` (READY: its deploys
1078
+ * are done) and returns the deployments of that commit; throws on a FAILED
1079
+ * import of it. Only for a restore whose link names no alias (unfazed.json
1080
+ * `apps[]`): the wait by commit waits for one app's deploy, and which apps
1081
+ * a commit deploys is known only once its import is done.
1082
+ */
1083
+ async #waitForImport(workspaceId, sha, waitOptions) {
1084
+ const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
1085
+ const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
1086
+ let last = null;
1087
+ await pollWithBackoff(async () => {
1088
+ const data = await this.client.query(ops.WorkspaceGitSourceDocument, { id: workspaceId }, requestOptions);
1089
+ const src = data.workspace?.gitSource ?? null;
1090
+ if (!src)
1091
+ throw new UnfazedNotFoundError(`unfazed: workspace ${workspaceId} has no git source`);
1092
+ last = src;
1093
+ if (src.lastImportedCommit !== sha)
1094
+ return { done: false };
1095
+ if (src.lastImportStatus === "READY")
1096
+ return { done: true, value: true };
1097
+ if (src.lastImportStatus === "FAILED") {
1098
+ throw new UnfazedDeployError("FAILED", "", null, `unfazed: the import of ${sha.slice(0, 7)} failed${src.lastImportError ? `: ${src.lastImportError}` : ""}`);
1099
+ }
1100
+ return { done: false };
1101
+ }, resolved, () => new UnfazedDeployError("TIMEOUT", "", null, `unfazed: ${sha.slice(0, 7)} was not imported after ${resolved.timeoutMs}ms` +
1102
+ (last ? ` (last import: ${last.lastImportStatus} ${last.lastImportedCommit?.slice(0, 7) ?? ""})` : "")));
1103
+ const page = await this.listDeployments({ workspaceID: workspaceId, limit: 50 }, requestOptions);
1104
+ return page.items.filter((d) => d.gitCommit === sha);
1105
+ }
1106
+ /* ------------------------------------------------------------- app errors
1107
+ *
1108
+ * Native error tracking: a service app's uncaught exceptions, unhandled
1109
+ * rejections, panics and Sentry SDK reports, grouped, kept on its server
1110
+ * for 30 days. These are MCP's error tools. Titles, stacks and requests are
1111
+ * what the app reported: untrusted text.
1112
+ */
1113
+ /** A service app's error groups, most recently seen first (MCP `list_errors`). */
1114
+ async listErrors(input, options) {
1115
+ const data = await this.client.query(ops.AppErrorsDocument, {
1116
+ workspaceID: input.workspaceId,
1117
+ alias: input.alias,
1118
+ status: input.status ?? null,
1119
+ limit: input.limit ?? null,
1120
+ cursor: input.cursor ?? null,
1121
+ }, options);
1122
+ return data.appErrors;
1123
+ }
1124
+ /**
1125
+ * One error group with its latest occurrence whole (a Sentry event: stack,
1126
+ * request), its recent occurrences and the deployments it was seen in (MCP
1127
+ * `get_error_group`).
1128
+ */
1129
+ async getErrorGroup(ref, options) {
1130
+ const data = await this.client.query(ops.AppErrorGroupDocument, { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId }, options);
1131
+ return data.appErrorGroup;
1132
+ }
1133
+ /**
1134
+ * Marks a group resolved in the app's current deployment, once its fix is
1135
+ * deployed (MCP `resolve_error_group`); another deployment reporting it
1136
+ * opens it again. Only on your human's say-so or a fix you verified.
1137
+ */
1138
+ async resolveErrorGroup(ref, options) {
1139
+ const data = await this.client.mutate(ops.ResolveErrorGroupDocument, { input: errorGroupInput(ref) }, options);
1140
+ return data.resolveAppErrorGroup;
1141
+ }
1142
+ /** Mutes an expected group: still counted, never regresses or notifies (MCP `mute_error_group`). */
1143
+ async muteErrorGroup(ref, options) {
1144
+ const data = await this.client.mutate(ops.MuteErrorGroupDocument, { input: errorGroupInput(ref) }, options);
1145
+ return data.muteAppErrorGroup;
1146
+ }
1147
+ /** Opens a resolved or muted group again (MCP `reopen_error_group`). */
1148
+ async reopenErrorGroup(ref, options) {
1149
+ const data = await this.client.mutate(ops.ReopenErrorGroupDocument, { input: errorGroupInput(ref) }, options);
1150
+ return data.reopenAppErrorGroup;
1151
+ }
1152
+ /* ------------------------------------------------------------------ events
1153
+ *
1154
+ * What happened to the apps, runs and domains you can see —
1155
+ * deployment.finished, service.crashed, error.new, error.regressed,
1156
+ * git.conflict, run.finished, … — instead of re-polling status. The same
1157
+ * feed and cursors as MCP `poll_events` / `wait_for_events`.
1158
+ */
1159
+ /** The event types, with each one's filters and payload schema (MCP `list_event_types`). */
1160
+ async listEventTypes(options) {
1161
+ const data = await this.client.query(ops.EventTypesDocument, {}, options);
1162
+ return data.platformEventTypes;
1163
+ }
1164
+ /**
1165
+ * Events since `cursor` (MCP `poll_events`). Omit `cursor` to start from now
1166
+ * — nothing is replayed — and pass the returned `cursor` back: at once while
1167
+ * `hasMore`, otherwise after `nextPollMs`, or to {@link waitForEvents}.
1168
+ * `truncated` means events may be missing: re-read state.
1169
+ */
1170
+ async pollEvents(input = {}, options) {
1171
+ const data = await this.client.query(ops.PollEventsDocument, { input: eventsInput(input) }, options);
1172
+ return data.pollEvents;
1173
+ }
1174
+ /**
1175
+ * {@link pollEvents} that waits server-side until an event arrives (MCP
1176
+ * `wait_for_events`), for up to `timeoutMs` (default 45 s; longer waits are
1177
+ * several server waits of up to 45 s, continuing from each cursor). A
1178
+ * timeout is not an error: the page has `timedOut: true`, no events and a
1179
+ * cursor to wait again with. The platform caps an account's waits in
1180
+ * progress (16, deploy waits included); past it this throws an error that
1181
+ * `isTooManyWaits` recognizes: poll then.
1182
+ *
1183
+ * ```ts
1184
+ * let page = await unfazed.pollEvents({ names: ["deployment.finished"] }); // from now
1185
+ * page = await unfazed.waitForEvents({ names: ["deployment.finished"], cursor: page.cursor, timeoutMs: 120_000 });
1186
+ * ```
1187
+ */
1188
+ async waitForEvents(input = {}, options = {}) {
1189
+ const now = options.now ?? (() => Date.now());
1190
+ const deadline = now() + (input.timeoutMs ?? SERVER_WAIT_SECONDS * 1000);
1191
+ let cursor = input.cursor;
1192
+ for (;;) {
1193
+ const remaining = deadline - now();
1194
+ const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
1195
+ const { now: _now, ...requestOptions } = options;
1196
+ const data = await this.client.query(ops.WaitForEventsDocument, { input: eventsInput({ ...input, ...(cursor === undefined ? {} : { cursor }) }), timeoutSeconds: seconds }, { ...requestOptions, timeoutMs: (seconds + 30) * 1000 });
1197
+ const page = data.waitForEvents;
1198
+ if (!page.timedOut || deadline - now() <= 0)
1199
+ return page;
1200
+ cursor = page.cursor;
1201
+ }
1202
+ }
637
1203
  /* --------------------------------------------------------------- agent runs
638
1204
  *
639
1205
  * A run is the past-tense counterpart to a deployment: boot a fresh VM off
@@ -854,7 +1420,47 @@ export class Unfazed {
854
1420
  return (workspaces.find((w) => w.name === name) ??
855
1421
  workspaces.find((w) => w.name.toLowerCase() === folded));
856
1422
  }
857
- async #writeBatch(workspaceId, batch, signal) {
1423
+ /** Set once the platform turned out to predate writeWorkspaceFiles. */
1424
+ #legacyWrites = false;
1425
+ /** Set once the platform turned out to predate the server-side deployment wait. */
1426
+ #noServerWait = false;
1427
+ /** One request of {@link writeFiles}: one call of the batch mutation, one commit. */
1428
+ async #writeBatch(workspaceId, batch, commitMessage, requestOptions) {
1429
+ try {
1430
+ const data = await this.client.mutate(ops.WriteFilesDocument, {
1431
+ workspaceID: workspaceId,
1432
+ files: batch.map((f) => ({
1433
+ path: f.path,
1434
+ content: f.content,
1435
+ ...(f.kind === "base64" ? { encoding: "base64" } : {}),
1436
+ })),
1437
+ commitMessage: commitMessage ?? null,
1438
+ }, requestOptions);
1439
+ const out = data.writeWorkspaceFiles;
1440
+ return {
1441
+ files: out.files.map((f) => ({ path: f.path, ok: f.ok, error: f.error ?? null, file: f.file ?? null })),
1442
+ written: out.written,
1443
+ failed: out.failed,
1444
+ totalBytes: out.totalBytes,
1445
+ commit: out.commit ?? null,
1446
+ };
1447
+ }
1448
+ catch (e) {
1449
+ if (!isUnsupportedField(e, "writeWorkspaceFiles") && !isUnsupportedField(e, "WorkspaceFileWriteInput"))
1450
+ throw e;
1451
+ // A platform older than the batch mutation: one mutation per file, as
1452
+ // before 0.8.0. It reports no commit.
1453
+ this.#legacyWrites = true;
1454
+ return this.#writeBatchLegacy(workspaceId, batch, requestOptions);
1455
+ }
1456
+ }
1457
+ /**
1458
+ * The pre-0.8.0 batch: one document with one ALIASED single-file mutation
1459
+ * per file. One request, but one commit per file on an app with platform
1460
+ * git, and no commit in the answer. Used only against platforms without
1461
+ * writeWorkspaceFiles.
1462
+ */
1463
+ async #writeBatchLegacy(workspaceId, batch, requestOptions) {
858
1464
  const varDefs = ["$workspaceID: ID!"];
859
1465
  const fields = [];
860
1466
  const variables = { workspaceID: workspaceId };
@@ -867,13 +1473,185 @@ export class Unfazed {
867
1473
  : ` f${i}: writeWorkspaceBase64File(input: { workspaceID: $workspaceID, path: $p${i}, contentBase64: $c${i} }) { file { ${FILE_SELECTION} } }`);
868
1474
  });
869
1475
  // Built at runtime rather than codegen'd: the field COUNT varies per call,
870
- // so no static document can express it. The field shapes are still fixed
871
- // and typed below, and each individual mutation is codegen-validated by its
872
- // single-file twin in operations.ts.
1476
+ // so no static document can express it. The field shapes are still fixed,
1477
+ // and each individual mutation is codegen-validated by its single-file
1478
+ // twin in operations.ts.
873
1479
  const document = `mutation SdkWriteFilesBatch(${varDefs.join(", ")}) {\n${fields.join("\n")}\n}`;
874
- const data = await this.client.mutate(document, variables, signal ? { signal } : {});
875
- return batch.map((_, i) => data[`f${i}`].file);
1480
+ let data;
1481
+ let errors = [];
1482
+ try {
1483
+ data = await this.client.mutate(document, variables, requestOptions);
1484
+ }
1485
+ catch (e) {
1486
+ // Fields answered independently: keep what landed, name what did not.
1487
+ if (!(e instanceof UnfazedGraphQLError) || !e.data)
1488
+ throw e;
1489
+ data = e.data;
1490
+ errors = e.errors;
1491
+ }
1492
+ const files = batch.map((f, i) => {
1493
+ const file = data[`f${i}`]?.file ?? null;
1494
+ if (file)
1495
+ return { path: f.path, ok: true, error: null, file };
1496
+ const error = errors.find((x) => x.path?.[0] === `f${i}`)?.message ?? "not written";
1497
+ return { path: f.path, ok: false, error, file: null };
1498
+ });
1499
+ const written = files.filter((f) => f.ok);
1500
+ return {
1501
+ files,
1502
+ written: written.length,
1503
+ failed: files.length - written.length,
1504
+ totalBytes: written.reduce((sum, f) => sum + (f.file?.sizeBytes ?? 0), 0),
1505
+ commit: null,
1506
+ };
1507
+ }
1508
+ }
1509
+ /** The platform's per-call limits of writeWorkspaceFiles (MCP's too). */
1510
+ const MAX_BATCH_FILES = 100;
1511
+ const MAX_BATCH_BYTES = 32 * 1024 * 1024;
1512
+ /** One server-side wait's budget, under the ~60 s a proxy or client allows a request. */
1513
+ const SERVER_WAIT_SECONDS = 45;
1514
+ /** How the platform says an app keeps no history in platform git. */
1515
+ const NO_PLATFORM_GIT = /platform git is not enabled|its history is there|has no git repository/i;
1516
+ /** The enum lists only the events it was written for; anything newer is UNKNOWN there. */
1517
+ function toDeploymentEvent(event) {
1518
+ return {
1519
+ id: event.id,
1520
+ deploymentID: event.deploymentID,
1521
+ type: event.rawType,
1522
+ typeEnum: event.type,
1523
+ rawType: event.rawType,
1524
+ severity: event.severity,
1525
+ message: event.message,
1526
+ vmID: event.vmID ?? null,
1527
+ metadata: event.metadata ?? null,
1528
+ timings: event.timings ? toPlatformTimings(event.timings) : null,
1529
+ createdAt: event.createdAt,
1530
+ };
1531
+ }
1532
+ const BUILD_FINISHED_EVENT = "deployment.build_finished";
1533
+ const DEPLOY_PHASES_EVENT = "deployment.phases";
1534
+ /**
1535
+ * A duration as the platform accepts one: a whole, non-negative number of
1536
+ * milliseconds (a number, or a string of digits as metadata carries it). A
1537
+ * fraction or anything else is not a duration; a negative one is a clock gone
1538
+ * backwards. Both are dropped, as the platform drops them.
1539
+ */
1540
+ function durationMs(value) {
1541
+ let n;
1542
+ if (typeof value === "number")
1543
+ n = value;
1544
+ else if (typeof value === "string" && /^[+-]?\d+$/.test(value.trim()))
1545
+ n = Number(value.trim());
1546
+ else
1547
+ return null;
1548
+ return Number.isSafeInteger(n) && n >= 0 ? n + 0 : null; // + 0: -0 is 0
1549
+ }
1550
+ /** The platform's structured timings, with every duration checked like the metadata's. */
1551
+ function toPlatformTimings(t) {
1552
+ const phases = {};
1553
+ if (t.phases && typeof t.phases === "object") {
1554
+ for (const [name, value] of Object.entries(t.phases)) {
1555
+ const ms = durationMs(value);
1556
+ if (ms !== null)
1557
+ phases[name] = ms;
1558
+ }
1559
+ }
1560
+ const memory = durationMs(t.buildMemoryMiB);
1561
+ return {
1562
+ totalMs: durationMs(t.totalMs),
1563
+ buildMs: durationMs(t.buildMs),
1564
+ promoteMs: durationMs(t.promoteMs),
1565
+ phases,
1566
+ buildMode: t.buildMode || null,
1567
+ artifactTransport: t.artifactTransport || null,
1568
+ buildMemoryMiB: memory ? memory : null,
1569
+ };
1570
+ }
1571
+ /**
1572
+ * Timing-event metadata read exactly as the platform reads it
1573
+ * (internal/usecases/deploy_timings.go): every `<name>_ms` that is a whole,
1574
+ * non-negative number; `total` only from the READY summary.
1575
+ */
1576
+ function timingsFromMetadata(metadata, ready) {
1577
+ const out = {
1578
+ totalMs: null,
1579
+ buildMs: null,
1580
+ promoteMs: null,
1581
+ phases: {},
1582
+ buildMode: metadata["build_mode"] || null,
1583
+ artifactTransport: metadata["artifact_transport"] || null,
1584
+ buildMemoryMiB: null,
1585
+ };
1586
+ for (const [key, raw] of Object.entries(metadata)) {
1587
+ if (!key.endsWith("_ms") || key === "_ms")
1588
+ continue;
1589
+ const ms = durationMs(raw);
1590
+ if (ms === null)
1591
+ continue;
1592
+ const name = key.slice(0, -3);
1593
+ if (name === "total") {
1594
+ if (ready)
1595
+ out.totalMs = ms;
1596
+ }
1597
+ else if (name === "build")
1598
+ out.buildMs = ms;
1599
+ else if (name === "promote")
1600
+ out.promoteMs = ms;
1601
+ else
1602
+ out.phases[name] = ms;
876
1603
  }
1604
+ const memory = durationMs(metadata["build_memory_mib"]);
1605
+ if (memory)
1606
+ out.buildMemoryMiB = memory;
1607
+ return out;
1608
+ }
1609
+ /**
1610
+ * A deploy's phases from its events, as the platform reads them: the latest
1611
+ * `deployment.phases` (written when it went READY: build, promotion and its
1612
+ * split, total) over the latest `deployment.build_finished` (the build VM's
1613
+ * own phases). It prefers the `timings` the platform parsed into each event
1614
+ * ({@link Unfazed.getEvents} reads them); events without them (an older
1615
+ * platform, events built by hand) are parsed from their `*_ms` metadata the
1616
+ * same way. Durations are whole, non-negative milliseconds: anything else is
1617
+ * dropped, as the platform drops it. Null when neither event is there.
1618
+ */
1619
+ export function parseDeploymentTimings(events) {
1620
+ const latest = (type) => {
1621
+ const event = [...events].reverse().find((e) => e.type === type);
1622
+ return event && (event.timings || event.metadata) ? event : null;
1623
+ };
1624
+ const build = latest(BUILD_FINISHED_EVENT);
1625
+ const ready = latest(DEPLOY_PHASES_EVENT);
1626
+ if (!build && !ready)
1627
+ return null;
1628
+ if ((!build || build.timings) && (!ready || ready.timings)) {
1629
+ const b = build?.timings ? toPlatformTimings(build.timings) : null;
1630
+ const r = ready?.timings ? toPlatformTimings(ready.timings) : null;
1631
+ return {
1632
+ totalMs: r?.totalMs ?? null,
1633
+ buildMs: r?.buildMs ?? b?.buildMs ?? null,
1634
+ promoteMs: r?.promoteMs ?? b?.promoteMs ?? null,
1635
+ phases: { ...b?.phases, ...r?.phases },
1636
+ buildMode: r?.buildMode ?? b?.buildMode ?? null,
1637
+ artifactTransport: r?.artifactTransport ?? b?.artifactTransport ?? null,
1638
+ buildMemoryMiB: r?.buildMemoryMiB ?? b?.buildMemoryMiB ?? null,
1639
+ };
1640
+ }
1641
+ // The platform merges the two events' metadata, the READY summary's values
1642
+ // over the build's, and parses the result.
1643
+ return timingsFromMetadata({ ...build?.metadata, ...ready?.metadata }, ready !== null);
1644
+ }
1645
+ function errorGroupInput(ref) {
1646
+ return { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId };
1647
+ }
1648
+ function eventsInput(input) {
1649
+ return {
1650
+ ...(input.names === undefined ? {} : { names: input.names }),
1651
+ ...(input.arguments === undefined ? {} : { arguments: input.arguments }),
1652
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1653
+ ...(input.max === undefined ? {} : { max: input.max }),
1654
+ };
877
1655
  }
878
1656
  const FILE_SELECTION = "path sizeBytes size sha256 updatedAt";
879
1657
  const ENV_SELECTION = "name value secret alias inherited masked updatedAt";