unfazed 0.6.0 → 0.8.0

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +299 -78
  3. package/REFERENCE.md +284 -0
  4. package/dist/cli/login.d.ts +129 -0
  5. package/dist/cli/login.js +596 -0
  6. package/dist/cli/login.js.map +1 -0
  7. package/dist/cli/workflow.d.ts +31 -0
  8. package/dist/cli/workflow.js +157 -0
  9. package/dist/cli/workflow.js.map +1 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +173 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/client.d.ts +26 -5
  14. package/dist/client.js +93 -48
  15. package/dist/client.js.map +1 -1
  16. package/dist/credentials.d.ts +123 -0
  17. package/dist/credentials.js +317 -0
  18. package/dist/credentials.js.map +1 -0
  19. package/dist/errors.d.ts +64 -0
  20. package/dist/errors.js +95 -0
  21. package/dist/errors.js.map +1 -1
  22. package/dist/generated/gql.d.ts +78 -2
  23. package/dist/generated/gql.js +21 -2
  24. package/dist/generated/gql.js.map +1 -1
  25. package/dist/generated/graphql.d.ts +505 -6
  26. package/dist/generated/graphql.js +439 -2
  27. package/dist/generated/graphql.js.map +1 -1
  28. package/dist/index.d.ts +25 -6
  29. package/dist/index.js +23 -5
  30. package/dist/index.js.map +1 -1
  31. package/dist/operations.d.ts +116 -5
  32. package/dist/operations.js +379 -7
  33. package/dist/operations.js.map +1 -1
  34. package/dist/reference.d.ts +18 -0
  35. package/dist/reference.generated.d.ts +2 -0
  36. package/dist/reference.generated.js +5 -0
  37. package/dist/reference.generated.js.map +1 -0
  38. package/dist/reference.js +38 -0
  39. package/dist/reference.js.map +1 -0
  40. package/dist/remote/app.js +2 -1
  41. package/dist/remote/app.js.map +1 -1
  42. package/dist/remote/world.d.ts +2 -2
  43. package/dist/remote/world.js +32 -16
  44. package/dist/remote/world.js.map +1 -1
  45. package/dist/unfazed.d.ts +545 -68
  46. package/dist/unfazed.js +688 -64
  47. package/dist/unfazed.js.map +1 -1
  48. package/dist/wait.d.ts +7 -0
  49. package/dist/wait.js.map +1 -1
  50. package/dist/workflow/nitro.js +5 -3
  51. package/dist/workflow/nitro.js.map +1 -1
  52. package/package.json +22 -6
  53. package/schema/SOURCE.json +4 -4
  54. package/schema/schema.graphqls +1628 -30
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
  /**
@@ -12,7 +12,7 @@ import { pollWithBackoff, resolveWaitOptions } from "./wait.js";
12
12
  * wrapped.
13
13
  *
14
14
  * ```ts
15
- * const unfazed = new Unfazed(); // reads UNFZ_API_TOKEN
15
+ * const unfazed = new Unfazed(); // UNFZ_API_TOKEN, else your `npx unfazed login`
16
16
  * const ws = await unfazed.createWorkspace("hello");
17
17
  * await unfazed.writeFiles(ws.id, { "server.ts": code });
18
18
  * const app = await unfazed.deployService({ workspaceId: ws.id, alias: "hello", wait: true });
@@ -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,119 @@ 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).
280
456
  *
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`.
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.
286
467
  */
287
468
  async waitForDeployment(deploymentId, waitOptions = {}) {
469
+ return (await this.#waitTerminal(deploymentId, waitOptions)).deployment;
470
+ }
471
+ async #waitTerminal(deploymentId, waitOptions) {
472
+ const now = waitOptions.now ?? (() => Date.now());
473
+ const started = now();
474
+ if ((waitOptions.mode ?? "server") === "server" && !this.#noServerWait) {
475
+ try {
476
+ return await this.#serverWait(deploymentId, waitOptions);
477
+ }
478
+ catch (e) {
479
+ if (isUnsupportedField(e, "waitForDeployment")) {
480
+ // An older platform: poll, and do not ask again on this instance.
481
+ this.#noServerWait = true;
482
+ }
483
+ else if (!isTooManyWaits(e)) {
484
+ throw e;
485
+ }
486
+ // Past the platform's cap on waits in progress (many parallel waits
487
+ // of one account): this wait polls instead, for what is left of its
488
+ // budget.
489
+ }
490
+ }
491
+ const timeoutMs = Math.max(0, (waitOptions.timeoutMs ?? 600_000) - (now() - started));
492
+ return this.#pollWait(deploymentId, { ...waitOptions, timeoutMs });
493
+ }
494
+ async #serverWait(deploymentId, waitOptions) {
288
495
  const resolved = resolveWaitOptions(waitOptions);
496
+ const deadline = resolved.now() + resolved.timeoutMs;
497
+ let last = null;
498
+ let landing = false;
499
+ for (let requests = 1;; requests++) {
500
+ if (resolved.signal?.aborted)
501
+ throw new UnfazedTimeoutError("unfazed: wait aborted");
502
+ const remaining = deadline - resolved.now();
503
+ if (requests > 1 && remaining <= 0) {
504
+ throw new UnfazedDeployError("TIMEOUT", deploymentId, last, `unfazed: deployment ${deploymentId} still ${last?.status ?? "BUILDING"}` +
505
+ `${landing ? " (READY, still landing on its server)" : ""} after ${resolved.timeoutMs}ms`);
506
+ }
507
+ const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
508
+ let answer;
509
+ try {
510
+ answer = (await this.client.query(ops.WaitForDeploymentDocument, { id: deploymentId, timeoutSeconds: seconds },
511
+ // The request is a long poll: give it the wait plus a round trip,
512
+ // whatever the client's default per-request timeout is.
513
+ { ...(resolved.signal ? { signal: resolved.signal } : {}), timeoutMs: (seconds + 30) * 1000 })).waitForDeployment;
514
+ }
515
+ catch (e) {
516
+ if (isNotFound(e, DEPLOYMENT_NOT_FOUND)) {
517
+ throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
518
+ }
519
+ throw e;
520
+ }
521
+ const d = answer.deployment;
522
+ last = d;
523
+ if (answer.timedOut) {
524
+ landing = d.status === "READY";
525
+ continue;
526
+ }
527
+ if (d.status === "READY")
528
+ return { deployment: d, requests, mode: "server" };
529
+ throw new UnfazedDeployError("FAILED", deploymentId, d, `unfazed: deployment ${deploymentId} ${d.status}${d.error ? `: ${d.error}` : ""}`);
530
+ }
531
+ }
532
+ async #pollWait(deploymentId, waitOptions) {
533
+ const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
289
534
  const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
290
535
  let last = null;
536
+ let requests = 0;
291
537
  await pollWithBackoff(async () => {
538
+ requests++;
292
539
  const data = await this.client.query(ops.PollDeploymentDocument, { id: deploymentId }, requestOptions);
293
540
  const d = data.deployment;
294
541
  if (!d) {
@@ -302,10 +549,39 @@ export class Unfazed {
302
549
  }
303
550
  return { done: false };
304
551
  }, resolved, () => new UnfazedDeployError("TIMEOUT", deploymentId, last, `unfazed: deployment ${deploymentId} still ${last?.status ?? "BUILDING"} after ${resolved.timeoutMs}ms`));
552
+ // The poll query is narrow; re-read the READY deployment in full so the
553
+ // answer has the shape deployService returns without `wait`.
305
554
  const full = await this.getDeployment(deploymentId, requestOptions);
306
555
  if (!full)
307
556
  throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
308
- return full;
557
+ return { deployment: full, requests: requests + 1, mode: "poll" };
558
+ }
559
+ /**
560
+ * The platform's measured phases of a deploy ({@link PlatformTimings}),
561
+ * from its timing events. Null when it recorded none (a deploy that is
562
+ * still building, or one from before timings existed).
563
+ */
564
+ async getDeploymentTimings(deploymentId, options) {
565
+ return parseDeploymentTimings(await this.getEvents(deploymentId, 100, options));
566
+ }
567
+ /**
568
+ * The timings of a deploy that just went READY. `deployment.phases` is
569
+ * written right after the READY transition, so a read that races it is
570
+ * retried once.
571
+ */
572
+ async #readyTimings(deploymentId, waitOptions, requestOptions) {
573
+ try {
574
+ const first = await this.getDeploymentTimings(deploymentId, requestOptions);
575
+ if (first?.totalMs != null)
576
+ return first;
577
+ const sleep = waitOptions?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
578
+ await sleep(250, requestOptions.signal);
579
+ return (await this.getDeploymentTimings(deploymentId, requestOptions)) ?? first;
580
+ }
581
+ catch {
582
+ // Timings are a diagnostic: never fail a deploy that went READY.
583
+ return null;
584
+ }
309
585
  }
310
586
  /** Reads one deployment by id. Null when absent. */
311
587
  async getDeployment(deploymentId, options) {
@@ -340,10 +616,25 @@ export class Unfazed {
340
616
  const data = await this.client.query(ops.ServiceLogsDocument, { deploymentID: deploymentId, tailBytes: input.tailBytes ?? null }, options);
341
617
  return data.serviceLogs;
342
618
  }
343
- /** Deployment lifecycle events — build, boot, probe, cron, restarts. */
619
+ /**
620
+ * Deployment lifecycle events — build, boot, probe, cron, restarts — the
621
+ * most recent `limit` (default 20), oldest first.
622
+ *
623
+ * `type` is the event's own name, as MCP reports it:
624
+ * `"deployment.build_finished"`, `"deployment.phases"`,
625
+ * `"service.vm_started"`, `"service.crashed"`. (`typeEnum` keeps the GraphQL
626
+ * enum, which reads `UNKNOWN` for names it does not list.) Timing events
627
+ * carry their phases in `metadata`; {@link getDeploymentTimings} turns them
628
+ * into numbers.
629
+ *
630
+ * ```ts
631
+ * const events = await unfazed.getEvents(app.id, 50);
632
+ * events.filter((e) => e.type === "deployment.build_finished");
633
+ * ```
634
+ */
344
635
  async getEvents(deploymentId, limit, options) {
345
636
  const data = await this.client.query(ops.DeploymentEventsDocument, { deploymentID: deploymentId, limit: limit ?? null }, options);
346
- return data.deploymentEvents;
637
+ return data.deploymentEvents.map(toDeploymentEvent);
347
638
  }
348
639
  /** Restarts a service's VM. */
349
640
  async restartService(deploymentId, options) {
@@ -357,6 +648,12 @@ export class Unfazed {
357
648
  }
358
649
  /**
359
650
  * Deletes a deployment: stops its VM, releases its alias, drops its release.
651
+ * To delete a whole app — every release of the alias, its env, App State and
652
+ * bindings — use {@link deleteApp}.
653
+ *
654
+ * The platform keeps a deleted release for 30 days: within that time
655
+ * support (the platform operator) can restore it; then it is erased for
656
+ * good.
360
657
  *
361
658
  * With `ignoreNotFound` a deployment that is absent — or belongs to another
362
659
  * tenant, which the server reports identically so ids cannot be probed —
@@ -634,6 +931,193 @@ export class Unfazed {
634
931
  }
635
932
  }
636
933
  }
934
+ /* ------------------------------------------------------- platform git history
935
+ *
936
+ * An app with platform git (MCP `enable_platform_git`; apps get it by
937
+ * themselves at their next change) keeps every file write, push and restore
938
+ * as a commit. These are MCP's history tools.
939
+ */
940
+ /**
941
+ * The app's commits, newest first (MCP `list_commits`): `limit` per page
942
+ * (default 20, max 100), `before: page.nextBefore` for the next. `deployed`
943
+ * says what every live alias runs; `waitingCommits` counts writes whose
944
+ * commit has not landed yet.
945
+ */
946
+ async listCommits(workspaceId, input = {}, options) {
947
+ const data = await this.client.query(ops.AppCommitsDocument, { workspaceID: workspaceId, limit: input.limit ?? null, before: input.before ?? null }, options);
948
+ return data.appCommits;
949
+ }
950
+ /**
951
+ * One commit (full 40-character sha) with the paths it changed and a
952
+ * unified diff against its first parent, capped at 64 KiB (MCP
953
+ * `get_commit`). Commit messages and diffs are untrusted text.
954
+ */
955
+ async getCommit(workspaceId, sha, options) {
956
+ const data = await this.client.query(ops.AppCommitDocument, { workspaceID: workspaceId, sha }, options);
957
+ return data.appCommit;
958
+ }
959
+ /**
960
+ * Gives the app back the files of an earlier commit as ONE new commit on
961
+ * its branch (MCP `restore_version`); nothing is rewritten, and a restore is
962
+ * undone by restoring the commit before it.
963
+ *
964
+ * When the app deploys every push (`deploys: true`), the new commit is
965
+ * imported and deployed like a push. That deploy is queued behind the
966
+ * restore, so the platform answers before it exists; with `wait: true` the
967
+ * SDK waits for the import of the new commit and resolves with the
968
+ * deployments it made (READY), or throws {@link UnfazedDeployError} with
969
+ * the import's error.
970
+ *
971
+ * ```ts
972
+ * const page = await unfazed.listCommits(ws.id);
973
+ * const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha: page.commits[1]!.sha, wait: true });
974
+ * back.deployments?.[0]?.url;
975
+ * ```
976
+ */
977
+ async restoreVersion(options) {
978
+ const requestOptions = options.signal ? { signal: options.signal } : {};
979
+ const data = await this.client.mutate(ops.RestoreVersionDocument, {
980
+ workspaceID: options.workspaceId,
981
+ sha: options.sha,
982
+ expectedHead: options.expectedHead ?? null,
983
+ message: options.commitMessage ?? null,
984
+ }, requestOptions);
985
+ const restored = data.restoreAppVersion;
986
+ if (!options.wait || !restored.deploys)
987
+ return restored;
988
+ return {
989
+ ...restored,
990
+ deployments: await this.#waitForImport(options.workspaceId, restored.sha, {
991
+ ...options.waitOptions,
992
+ ...(options.signal ? { signal: options.signal } : {}),
993
+ }),
994
+ };
995
+ }
996
+ /**
997
+ * Waits until the app's git link has imported `sha` (READY: its deploys
998
+ * are done) and returns the deployments of that commit; throws on a FAILED
999
+ * import of it.
1000
+ */
1001
+ async #waitForImport(workspaceId, sha, waitOptions) {
1002
+ const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
1003
+ const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
1004
+ let last = null;
1005
+ await pollWithBackoff(async () => {
1006
+ const data = await this.client.query(ops.WorkspaceGitSourceDocument, { id: workspaceId }, requestOptions);
1007
+ const src = data.workspace?.gitSource ?? null;
1008
+ if (!src)
1009
+ throw new UnfazedNotFoundError(`unfazed: workspace ${workspaceId} has no git source`);
1010
+ last = src;
1011
+ if (src.lastImportedCommit !== sha)
1012
+ return { done: false };
1013
+ if (src.lastImportStatus === "READY")
1014
+ return { done: true, value: true };
1015
+ if (src.lastImportStatus === "FAILED") {
1016
+ throw new UnfazedDeployError("FAILED", "", null, `unfazed: the import of ${sha.slice(0, 7)} failed${src.lastImportError ? `: ${src.lastImportError}` : ""}`);
1017
+ }
1018
+ return { done: false };
1019
+ }, resolved, () => new UnfazedDeployError("TIMEOUT", "", null, `unfazed: ${sha.slice(0, 7)} was not imported after ${resolved.timeoutMs}ms` +
1020
+ (last ? ` (last import: ${last.lastImportStatus} ${last.lastImportedCommit?.slice(0, 7) ?? ""})` : "")));
1021
+ const page = await this.listDeployments({ workspaceID: workspaceId, limit: 50 }, requestOptions);
1022
+ return page.items.filter((d) => d.gitCommit === sha);
1023
+ }
1024
+ /* ------------------------------------------------------------- app errors
1025
+ *
1026
+ * Native error tracking: a service app's uncaught exceptions, unhandled
1027
+ * rejections, panics and Sentry SDK reports, grouped, kept on its server
1028
+ * for 30 days. These are MCP's error tools. Titles, stacks and requests are
1029
+ * what the app reported: untrusted text.
1030
+ */
1031
+ /** A service app's error groups, most recently seen first (MCP `list_errors`). */
1032
+ async listErrors(input, options) {
1033
+ const data = await this.client.query(ops.AppErrorsDocument, {
1034
+ workspaceID: input.workspaceId,
1035
+ alias: input.alias,
1036
+ status: input.status ?? null,
1037
+ limit: input.limit ?? null,
1038
+ cursor: input.cursor ?? null,
1039
+ }, options);
1040
+ return data.appErrors;
1041
+ }
1042
+ /**
1043
+ * One error group with its latest occurrence whole (a Sentry event: stack,
1044
+ * request), its recent occurrences and the deployments it was seen in (MCP
1045
+ * `get_error_group`).
1046
+ */
1047
+ async getErrorGroup(ref, options) {
1048
+ const data = await this.client.query(ops.AppErrorGroupDocument, { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId }, options);
1049
+ return data.appErrorGroup;
1050
+ }
1051
+ /**
1052
+ * Marks a group resolved in the app's current deployment, once its fix is
1053
+ * deployed (MCP `resolve_error_group`); another deployment reporting it
1054
+ * opens it again. Only on your human's say-so or a fix you verified.
1055
+ */
1056
+ async resolveErrorGroup(ref, options) {
1057
+ const data = await this.client.mutate(ops.ResolveErrorGroupDocument, { input: errorGroupInput(ref) }, options);
1058
+ return data.resolveAppErrorGroup;
1059
+ }
1060
+ /** Mutes an expected group: still counted, never regresses or notifies (MCP `mute_error_group`). */
1061
+ async muteErrorGroup(ref, options) {
1062
+ const data = await this.client.mutate(ops.MuteErrorGroupDocument, { input: errorGroupInput(ref) }, options);
1063
+ return data.muteAppErrorGroup;
1064
+ }
1065
+ /** Opens a resolved or muted group again (MCP `reopen_error_group`). */
1066
+ async reopenErrorGroup(ref, options) {
1067
+ const data = await this.client.mutate(ops.ReopenErrorGroupDocument, { input: errorGroupInput(ref) }, options);
1068
+ return data.reopenAppErrorGroup;
1069
+ }
1070
+ /* ------------------------------------------------------------------ events
1071
+ *
1072
+ * What happened to the apps, runs and domains you can see —
1073
+ * deployment.finished, service.crashed, error.new, error.regressed,
1074
+ * git.conflict, run.finished, … — instead of re-polling status. The same
1075
+ * feed and cursors as MCP `poll_events` / `wait_for_events`.
1076
+ */
1077
+ /** The event types, with each one's filters and payload schema (MCP `list_event_types`). */
1078
+ async listEventTypes(options) {
1079
+ const data = await this.client.query(ops.EventTypesDocument, {}, options);
1080
+ return data.platformEventTypes;
1081
+ }
1082
+ /**
1083
+ * Events since `cursor` (MCP `poll_events`). Omit `cursor` to start from now
1084
+ * — nothing is replayed — and pass the returned `cursor` back: at once while
1085
+ * `hasMore`, otherwise after `nextPollMs`, or to {@link waitForEvents}.
1086
+ * `truncated` means events may be missing: re-read state.
1087
+ */
1088
+ async pollEvents(input = {}, options) {
1089
+ const data = await this.client.query(ops.PollEventsDocument, { input: eventsInput(input) }, options);
1090
+ return data.pollEvents;
1091
+ }
1092
+ /**
1093
+ * {@link pollEvents} that waits server-side until an event arrives (MCP
1094
+ * `wait_for_events`), for up to `timeoutMs` (default 45 s; longer waits are
1095
+ * several server waits of up to 45 s, continuing from each cursor). A
1096
+ * timeout is not an error: the page has `timedOut: true`, no events and a
1097
+ * cursor to wait again with. The platform caps an account's waits in
1098
+ * progress (16, deploy waits included); past it this throws an error that
1099
+ * `isTooManyWaits` recognizes: poll then.
1100
+ *
1101
+ * ```ts
1102
+ * let page = await unfazed.pollEvents({ names: ["deployment.finished"] }); // from now
1103
+ * page = await unfazed.waitForEvents({ names: ["deployment.finished"], cursor: page.cursor, timeoutMs: 120_000 });
1104
+ * ```
1105
+ */
1106
+ async waitForEvents(input = {}, options = {}) {
1107
+ const now = options.now ?? (() => Date.now());
1108
+ const deadline = now() + (input.timeoutMs ?? SERVER_WAIT_SECONDS * 1000);
1109
+ let cursor = input.cursor;
1110
+ for (;;) {
1111
+ const remaining = deadline - now();
1112
+ const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
1113
+ const { now: _now, ...requestOptions } = options;
1114
+ const data = await this.client.query(ops.WaitForEventsDocument, { input: eventsInput({ ...input, ...(cursor === undefined ? {} : { cursor }) }), timeoutSeconds: seconds }, { ...requestOptions, timeoutMs: (seconds + 30) * 1000 });
1115
+ const page = data.waitForEvents;
1116
+ if (!page.timedOut || deadline - now() <= 0)
1117
+ return page;
1118
+ cursor = page.cursor;
1119
+ }
1120
+ }
637
1121
  /* --------------------------------------------------------------- agent runs
638
1122
  *
639
1123
  * A run is the past-tense counterpart to a deployment: boot a fresh VM off
@@ -854,7 +1338,47 @@ export class Unfazed {
854
1338
  return (workspaces.find((w) => w.name === name) ??
855
1339
  workspaces.find((w) => w.name.toLowerCase() === folded));
856
1340
  }
857
- async #writeBatch(workspaceId, batch, signal) {
1341
+ /** Set once the platform turned out to predate writeWorkspaceFiles. */
1342
+ #legacyWrites = false;
1343
+ /** Set once the platform turned out to predate the server-side deployment wait. */
1344
+ #noServerWait = false;
1345
+ /** One request of {@link writeFiles}: one call of the batch mutation, one commit. */
1346
+ async #writeBatch(workspaceId, batch, commitMessage, requestOptions) {
1347
+ try {
1348
+ const data = await this.client.mutate(ops.WriteFilesDocument, {
1349
+ workspaceID: workspaceId,
1350
+ files: batch.map((f) => ({
1351
+ path: f.path,
1352
+ content: f.content,
1353
+ ...(f.kind === "base64" ? { encoding: "base64" } : {}),
1354
+ })),
1355
+ commitMessage: commitMessage ?? null,
1356
+ }, requestOptions);
1357
+ const out = data.writeWorkspaceFiles;
1358
+ return {
1359
+ files: out.files.map((f) => ({ path: f.path, ok: f.ok, error: f.error ?? null, file: f.file ?? null })),
1360
+ written: out.written,
1361
+ failed: out.failed,
1362
+ totalBytes: out.totalBytes,
1363
+ commit: out.commit ?? null,
1364
+ };
1365
+ }
1366
+ catch (e) {
1367
+ if (!isUnsupportedField(e, "writeWorkspaceFiles") && !isUnsupportedField(e, "WorkspaceFileWriteInput"))
1368
+ throw e;
1369
+ // A platform older than the batch mutation: one mutation per file, as
1370
+ // before 0.8.0. It reports no commit.
1371
+ this.#legacyWrites = true;
1372
+ return this.#writeBatchLegacy(workspaceId, batch, requestOptions);
1373
+ }
1374
+ }
1375
+ /**
1376
+ * The pre-0.8.0 batch: one document with one ALIASED single-file mutation
1377
+ * per file. One request, but one commit per file on an app with platform
1378
+ * git, and no commit in the answer. Used only against platforms without
1379
+ * writeWorkspaceFiles.
1380
+ */
1381
+ async #writeBatchLegacy(workspaceId, batch, requestOptions) {
858
1382
  const varDefs = ["$workspaceID: ID!"];
859
1383
  const fields = [];
860
1384
  const variables = { workspaceID: workspaceId };
@@ -867,13 +1391,113 @@ export class Unfazed {
867
1391
  : ` f${i}: writeWorkspaceBase64File(input: { workspaceID: $workspaceID, path: $p${i}, contentBase64: $c${i} }) { file { ${FILE_SELECTION} } }`);
868
1392
  });
869
1393
  // 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.
1394
+ // so no static document can express it. The field shapes are still fixed,
1395
+ // and each individual mutation is codegen-validated by its single-file
1396
+ // twin in operations.ts.
873
1397
  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);
1398
+ let data;
1399
+ let errors = [];
1400
+ try {
1401
+ data = await this.client.mutate(document, variables, requestOptions);
1402
+ }
1403
+ catch (e) {
1404
+ // Fields answered independently: keep what landed, name what did not.
1405
+ if (!(e instanceof UnfazedGraphQLError) || !e.data)
1406
+ throw e;
1407
+ data = e.data;
1408
+ errors = e.errors;
1409
+ }
1410
+ const files = batch.map((f, i) => {
1411
+ const file = data[`f${i}`]?.file ?? null;
1412
+ if (file)
1413
+ return { path: f.path, ok: true, error: null, file };
1414
+ const error = errors.find((x) => x.path?.[0] === `f${i}`)?.message ?? "not written";
1415
+ return { path: f.path, ok: false, error, file: null };
1416
+ });
1417
+ const written = files.filter((f) => f.ok);
1418
+ return {
1419
+ files,
1420
+ written: written.length,
1421
+ failed: files.length - written.length,
1422
+ totalBytes: written.reduce((sum, f) => sum + (f.file?.sizeBytes ?? 0), 0),
1423
+ commit: null,
1424
+ };
1425
+ }
1426
+ }
1427
+ /** The platform's per-call limits of writeWorkspaceFiles (MCP's too). */
1428
+ const MAX_BATCH_FILES = 100;
1429
+ const MAX_BATCH_BYTES = 32 * 1024 * 1024;
1430
+ /** One server-side wait's budget, under the ~60 s a proxy or client allows a request. */
1431
+ const SERVER_WAIT_SECONDS = 45;
1432
+ /** How the platform says an app keeps no history in platform git. */
1433
+ const NO_PLATFORM_GIT = /platform git is not enabled|its history is there|has no git repository/i;
1434
+ /** The enum lists only the events it was written for; anything newer is UNKNOWN there. */
1435
+ function toDeploymentEvent(event) {
1436
+ return {
1437
+ id: event.id,
1438
+ deploymentID: event.deploymentID,
1439
+ type: event.rawType,
1440
+ typeEnum: event.type,
1441
+ rawType: event.rawType,
1442
+ severity: event.severity,
1443
+ message: event.message,
1444
+ vmID: event.vmID ?? null,
1445
+ metadata: event.metadata ?? null,
1446
+ createdAt: event.createdAt,
1447
+ };
1448
+ }
1449
+ /**
1450
+ * A deploy's phases from its events: `deployment.phases` (written when it went
1451
+ * READY: build, promotion and its split, total) over `deployment.build_finished`
1452
+ * (the build VM's own phases). Every `*_ms` field becomes a number. Null when
1453
+ * neither event is there.
1454
+ */
1455
+ export function parseDeploymentTimings(events) {
1456
+ const latest = (type) => [...events].reverse().find((e) => e.type === type)?.metadata ?? null;
1457
+ const build = latest("deployment.build_finished");
1458
+ const ready = latest("deployment.phases");
1459
+ if (!build && !ready)
1460
+ return null;
1461
+ const merged = { ...build, ...ready };
1462
+ const ms = (name) => {
1463
+ const value = merged[`${name}_ms`];
1464
+ if (value === undefined)
1465
+ return null;
1466
+ const n = Number(value);
1467
+ return Number.isFinite(n) ? n : null;
1468
+ };
1469
+ const phases = {};
1470
+ for (const [key, value] of Object.entries(merged)) {
1471
+ if (!key.endsWith("_ms"))
1472
+ continue;
1473
+ const name = key.slice(0, -3);
1474
+ if (name === "total" || name === "build" || name === "promote")
1475
+ continue;
1476
+ const n = Number(value);
1477
+ if (Number.isFinite(n))
1478
+ phases[name] = n;
876
1479
  }
1480
+ const memory = Number(merged["build_memory_mib"]);
1481
+ return {
1482
+ totalMs: ready ? ms("total") : null,
1483
+ buildMs: ms("build"),
1484
+ promoteMs: ms("promote"),
1485
+ phases,
1486
+ buildMode: merged["build_mode"] ?? null,
1487
+ artifactTransport: merged["artifact_transport"] ?? null,
1488
+ buildMemoryMiB: Number.isFinite(memory) && merged["build_memory_mib"] !== undefined ? memory : null,
1489
+ };
1490
+ }
1491
+ function errorGroupInput(ref) {
1492
+ return { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId };
1493
+ }
1494
+ function eventsInput(input) {
1495
+ return {
1496
+ ...(input.names === undefined ? {} : { names: input.names }),
1497
+ ...(input.arguments === undefined ? {} : { arguments: input.arguments }),
1498
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1499
+ ...(input.max === undefined ? {} : { max: input.max }),
1500
+ };
877
1501
  }
878
1502
  const FILE_SELECTION = "path sizeBytes size sha256 updatedAt";
879
1503
  const ENV_SELECTION = "name value secret alias inherited masked updatedAt";