unfazed 0.1.0 → 0.3.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.
package/dist/unfazed.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { UnfazedClient } from "./client.js";
2
- import { UnfazedConflictError, UnfazedDeployError, UnfazedGraphQLError, UnfazedNotFoundError, looksLikeCASConflict, } from "./errors.js";
2
+ import { DEPLOYMENT_NOT_FOUND, UnfazedConflictError, UnfazedDeployError, UnfazedGraphQLError, UnfazedNotFoundError, UnfazedRunError, WORKSPACE_NOT_FOUND, isDuplicateWorkspaceName, isNotFoundSentinel as isNotFound, looksLikeCASConflict, } from "./errors.js";
3
3
  import * as ops from "./operations.js";
4
4
  import { pollWithBackoff, resolveWaitOptions } from "./wait.js";
5
5
  /**
@@ -46,10 +46,77 @@ export class Unfazed {
46
46
  /**
47
47
  * Deletes a workspace and everything anchored to it — deployments, env, and
48
48
  * a BOX's persistent data disk. Not recoverable.
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:
61
+ *
62
+ * ```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
66
+ * ```
67
+ *
68
+ * With `ignoreNotFound` a workspace that is absent (or belongs to someone
69
+ * else — the server returns one indistinguishable sentinel for both, on
70
+ * purpose) resolves to `false` instead of throwing, which makes teardown in
71
+ * a `finally` block idempotent.
49
72
  */
50
- async deleteWorkspace(workspaceId, options) {
51
- const data = await this.client.mutate(ops.DeleteWorkspaceDocument, { workspaceID: workspaceId }, options);
52
- return data.deleteWorkspace.ok;
73
+ async deleteWorkspace(workspaceId, options = {}) {
74
+ const withDeployments = options.withDeployments ?? true;
75
+ try {
76
+ const data = await this.client.mutate(ops.DeleteWorkspaceDocument, { workspaceID: workspaceId, withDeployments }, options);
77
+ return data.deleteWorkspace.ok;
78
+ }
79
+ catch (e) {
80
+ if (options.ignoreNotFound && isNotFound(e, WORKSPACE_NOT_FOUND))
81
+ return false;
82
+ throw e;
83
+ }
84
+ }
85
+ /**
86
+ * Returns the workspace named `name`, creating it only if it does not exist.
87
+ *
88
+ * Workspace names are unique per owner, case-insensitively — a second
89
+ * `createWorkspace("Hello")` after a `createWorkspace("hello")` is an error,
90
+ * not a second workspace. That makes the bare create a poor fit for any
91
+ * script that might run twice; this is the re-runnable form.
92
+ *
93
+ * ```ts
94
+ * const ws = await unfazed.ensureWorkspace("greeter"); // safe to re-run
95
+ * ```
96
+ *
97
+ * Costs one list, plus one create only on a miss. The create is still
98
+ * guarded: if a concurrent caller wins the race, the duplicate-name error is
99
+ * caught and the now-existing workspace is returned instead of thrown.
100
+ */
101
+ async ensureWorkspace(name, options) {
102
+ const existing = await this.#findWorkspaceByName(name, options);
103
+ if (existing)
104
+ return existing;
105
+ try {
106
+ return await this.createWorkspace(name, options);
107
+ }
108
+ catch (e) {
109
+ // Lost a create/create race, or the server's case folding is stricter
110
+ // than ours. Either way the workspace exists now — re-read rather than
111
+ // failing a script whose intent was "make sure this exists".
112
+ if (!isDuplicateWorkspaceName(e))
113
+ throw e;
114
+ const raced = await this.#findWorkspaceByName(name, options);
115
+ if (raced)
116
+ return raced;
117
+ // The name is taken by something we cannot see. Surface the real error.
118
+ throw e;
119
+ }
53
120
  }
54
121
  /* ------------------------------------------------------------------- files */
55
122
  /**
@@ -106,17 +173,36 @@ export class Unfazed {
106
173
  const data = await this.client.query(ops.ReadFileDocument, { input: { workspaceID: workspaceId, path, ...input } }, options);
107
174
  return data.readWorkspaceFile;
108
175
  }
109
- /** Deletes a workspace file. */
176
+ /**
177
+ * Deletes a workspace file.
178
+ *
179
+ * Returns `false` when there was nothing to delete — an absent path is not
180
+ * an error. See the `ok: false` convention on {@link deleteEnv}.
181
+ */
110
182
  async deleteFile(workspaceId, path, options) {
111
183
  const data = await this.client.mutate(ops.DeleteFileDocument, { workspaceID: workspaceId, path }, options);
112
184
  return data.deleteWorkspaceFile.ok;
113
185
  }
114
- /* ----------------------------------------------------------------- deploys */
186
+ /* ----------------------------------------------------------------- deploys
187
+ *
188
+ * Every deploy method here MINTS A NEW RELEASE on every call. There is no
189
+ * content dedupe: deploying byte-identical files twice produces two distinct
190
+ * releases with two ids, both real, both counting against retention (which
191
+ * keeps only the most recent few per alias and prunes the rest). Deploying
192
+ * in a retry loop is therefore not free — it churns the release history and
193
+ * can push a release you still wanted to roll back to out of retention.
194
+ * Check {@link getDeployment} or {@link listReleases} before redeploying if
195
+ * you only meant to converge.
196
+ */
115
197
  /**
116
198
  * Deploys the workspace as a SERVICE (an HTTP server).
117
199
  *
118
200
  * With `wait: true` this resolves only once the build, VM boot and health
119
201
  * probe have all passed.
202
+ *
203
+ * Passing the same `alias` again redeploys that alias — a new release that
204
+ * takes over the alias when it goes READY. Omitting `alias` lets the server
205
+ * generate one, which means a different app on every call.
120
206
  */
121
207
  deployService(options) {
122
208
  const { workspaceId, alias, runtime, serviceMode, healthPath, cronSchedule, cronPath } = options;
@@ -145,6 +231,25 @@ export class Unfazed {
145
231
  * Deploys a BOX: a persistent always-on Linux VM with a data disk that
146
232
  * survives stops and redeploys. A dev machine, not an HTTP service. The
147
233
  * result's `sshCommand` is how you get in.
234
+ *
235
+ * ⚠️ **Omitting `alias` creates a NEW BOX every single call.** There is no
236
+ * "the workspace's box" to update: with no alias the server generates a
237
+ * fresh random one, so a script run twice leaves you with two box records,
238
+ * not one redeployed box.
239
+ *
240
+ * The data disk, meanwhile, is keyed by WORKSPACE — one disk, shared by
241
+ * every box in the workspace, and it is the box's real identity. Since only
242
+ * one VM can hold that disk read-write, the second box cannot even start
243
+ * while the first is running (`box data disk for workspace ... is attached
244
+ * to VM ...; stop it first`). The result is a pile of dead box records
245
+ * fighting over one disk.
246
+ *
247
+ * **Always pass an explicit `alias` in anything re-runnable.** Redeploying
248
+ * the same alias is the in-place update you actually want.
249
+ *
250
+ * ```ts
251
+ * await unfazed.deployBox({ workspaceId: ws.id, alias: "dev" }); // re-runnable
252
+ * ```
148
253
  */
149
254
  deployBox(options) {
150
255
  const { workspaceId, alias, resources } = options;
@@ -250,10 +355,23 @@ export class Unfazed {
250
355
  const data = await this.client.mutate(ops.StopServiceDocument, { deploymentID: deploymentId }, options);
251
356
  return data.stopService;
252
357
  }
253
- /** Deletes a deployment. */
254
- async deleteDeployment(deploymentId, options) {
255
- const data = await this.client.mutate(ops.DeleteDeploymentDocument, { deploymentID: deploymentId }, options);
256
- return data.deleteDeployment.ok;
358
+ /**
359
+ * Deletes a deployment: stops its VM, releases its alias, drops its release.
360
+ *
361
+ * With `ignoreNotFound` a deployment that is absent — or belongs to another
362
+ * tenant, which the server reports identically so ids cannot be probed —
363
+ * resolves to `false` instead of throwing, making cleanup loops idempotent.
364
+ */
365
+ async deleteDeployment(deploymentId, options = {}) {
366
+ try {
367
+ const data = await this.client.mutate(ops.DeleteDeploymentDocument, { deploymentID: deploymentId }, options);
368
+ return data.deleteDeployment.ok;
369
+ }
370
+ catch (e) {
371
+ if (options.ignoreNotFound && isNotFound(e, DEPLOYMENT_NOT_FOUND))
372
+ return false;
373
+ throw e;
374
+ }
257
375
  }
258
376
  /* ------------------------------------------------------------ env & secrets
259
377
  *
@@ -262,12 +380,25 @@ export class Unfazed {
262
380
  * deploy time — changing either does not affect a running app until you
263
381
  * redeploy that alias. Old releases are immutable, and STATIC gets no env.
264
382
  */
265
- /** Sets a workspace-default env var. Redeploy the alias to apply it. */
383
+ /**
384
+ * Sets a workspace-default env var. Redeploy the alias to apply it.
385
+ *
386
+ * A name is either plain or secret, never both: this DELETES a workspace
387
+ * secret of the same name. {@link setSecret} displaces a plain var the same
388
+ * way. Last write wins, and the kind flips silently — so re-running a setup
389
+ * script that calls `setSecret("API_KEY", ...)` after one that called
390
+ * `setEnv("API_KEY", ...)` quietly converts it, and vice versa.
391
+ */
266
392
  async setEnv(workspaceId, name, value, options) {
267
393
  const data = await this.client.mutate(ops.SetWorkspaceEnvDocument, { workspaceID: workspaceId, name, value }, options);
268
394
  return data.setWorkspaceEnv.env;
269
395
  }
270
- /** Sets a workspace-default secret. The value is never readable again. */
396
+ /**
397
+ * Sets a workspace-default secret. The value is never readable again.
398
+ *
399
+ * Mutually exclusive with {@link setEnv}: this DELETES a plain workspace var
400
+ * of the same name. See {@link setEnv} for the displacement rule.
401
+ */
271
402
  async setSecret(workspaceId, name, value, options) {
272
403
  const data = await this.client.mutate(ops.SetWorkspaceSecretDocument, { workspaceID: workspaceId, name, value }, options);
273
404
  return data.setWorkspaceSecret.env;
@@ -277,6 +408,16 @@ export class Unfazed {
277
408
  * the same batching trick as {@link writeFiles}.
278
409
  *
279
410
  * Pass `{ secret: true }` to route them all to `setWorkspaceSecret`.
411
+ *
412
+ * It inherits {@link writeFiles}' failure semantics too: the batch is NOT
413
+ * atomic. GraphQL resolves each aliased field independently, so a rejected
414
+ * name (over the limit, malformed, whatever) leaves the vars before it
415
+ * already committed. The thrown {@link UnfazedGraphQLError} carries `data`
416
+ * with the aliases that succeeded and `errors[].path` naming those that did
417
+ * not — do not assume "it threw" means "nothing was written".
418
+ *
419
+ * Each name still displaces its opposite kind, exactly as {@link setEnv} and
420
+ * {@link setSecret} do one at a time.
280
421
  */
281
422
  async setEnvMany(workspaceId, vars, options = {}) {
282
423
  const entries = Object.entries(vars);
@@ -296,7 +437,16 @@ export class Unfazed {
296
437
  const data = await this.client.mutate(document, variables, options.signal ? { signal: options.signal } : {});
297
438
  return entries.map((_, i) => data[`e${i}`].env);
298
439
  }
299
- /** Deletes a workspace-default env var. */
440
+ /**
441
+ * Deletes a workspace-default env var (plain or secret).
442
+ *
443
+ * **The `ok: false` convention.** A `false` here does NOT mean failure — it
444
+ * means "nothing to do, the world is already in the state you asked for".
445
+ * The var did not exist. Every `ok`-returning mutation in this SDK follows
446
+ * the rule ({@link deleteFile}, {@link inheritAppEnv}, {@link removeAppEnv}),
447
+ * so the idempotent-cleanup pattern is simply to ignore the boolean; a real
448
+ * failure throws.
449
+ */
300
450
  async deleteEnv(workspaceId, name, options) {
301
451
  const data = await this.client.mutate(ops.DeleteWorkspaceEnvDocument, { workspaceID: workspaceId, name }, options);
302
452
  return data.deleteWorkspaceEnv.ok;
@@ -306,12 +456,24 @@ export class Unfazed {
306
456
  const data = await this.client.query(ops.ListWorkspaceEnvDocument, { workspaceID: workspaceId }, options);
307
457
  return data.workspaceEnv;
308
458
  }
309
- /** Sets an env var for ONE alias, overriding the workspace default. */
459
+ /**
460
+ * Sets an env var for ONE alias, overriding the workspace default.
461
+ *
462
+ * Displaces both of the other states this name could be in for the app: it
463
+ * deletes an app SECRET of the same name, and it CLEARS a REMOVE mask left
464
+ * by {@link removeAppEnv}. So setting a value is also the way to un-block a
465
+ * masked name, and a name is only ever one of plain / secret / masked.
466
+ */
310
467
  async setAppEnv(workspaceId, alias, name, value, options) {
311
468
  const data = await this.client.mutate(ops.SetAppEnvDocument, { workspaceID: workspaceId, alias, name, value }, options);
312
469
  return data.setAppEnv.env;
313
470
  }
314
- /** Sets a secret for ONE alias, overriding the workspace default. */
471
+ /**
472
+ * Sets a secret for ONE alias, overriding the workspace default.
473
+ *
474
+ * Like {@link setAppEnv}, this deletes a plain app var of the same name and
475
+ * clears a REMOVE mask for it.
476
+ */
315
477
  async setAppSecret(workspaceId, alias, name, value, options) {
316
478
  const data = await this.client.mutate(ops.SetAppSecretDocument, { workspaceID: workspaceId, alias, name, value }, options);
317
479
  return data.setAppSecret.env;
@@ -320,12 +482,36 @@ export class Unfazed {
320
482
  * REMOVE: blocks this name for the app entirely — the app override, the
321
483
  * workspace default, and integration enrichment. Not the same as deleting an
322
484
  * override; use {@link inheritAppEnv} for that.
485
+ *
486
+ * This WRITES A MASK, and the mask is a real, persistent row. Two things
487
+ * follow that surprise people:
488
+ *
489
+ * - It is written even when nothing was inherited. Masking a name that no
490
+ * workspace default and no integration ever provided is not a no-op; it
491
+ * leaves a tombstone that keeps blocking the name if a default appears
492
+ * later. Clear it with {@link inheritAppEnv}.
493
+ * - It COSTS A SLOT. A workspace gets 128 env entries total — that budget
494
+ * is shared across the workspace defaults AND every app scope combined,
495
+ * not 128 per scope — and masks count toward it just like values do.
496
+ * Masking names in a loop can exhaust the workspace's env budget.
497
+ *
498
+ * Returns `false` when the exact mask was already present (a second call for
499
+ * the same name), per the {@link deleteEnv} `ok: false` convention. It
500
+ * returns `true` when it had something to change — including converting an
501
+ * existing override into a mask.
323
502
  */
324
503
  async removeAppEnv(workspaceId, alias, name, options) {
325
504
  const data = await this.client.mutate(ops.RemoveAppEnvDocument, { workspaceID: workspaceId, alias, name }, options);
326
505
  return data.removeAppEnv.ok;
327
506
  }
328
- /** INHERIT: clears the app override/mask so workspace and integration values apply again. */
507
+ /**
508
+ * INHERIT: clears the app override/mask so workspace and integration values
509
+ * apply again. This is the undo for both {@link setAppEnv} and
510
+ * {@link removeAppEnv}.
511
+ *
512
+ * Returns `false` when there was no override and no mask to clear — nothing
513
+ * to do, not a failure. See {@link deleteEnv}.
514
+ */
329
515
  async inheritAppEnv(workspaceId, alias, name, options) {
330
516
  const data = await this.client.mutate(ops.InheritAppEnvDocument, { workspaceID: workspaceId, alias, name }, options);
331
517
  return data.inheritAppEnv.ok;
@@ -365,11 +551,31 @@ export class Unfazed {
365
551
  * ```ts
366
552
  * await unfazed.rollback({ alias: "my-app" }); // undo last deploy
367
553
  * await unfazed.rollback({ alias: "my-app", to: relId }); // to a specific release
554
+ * await unfazed.rollback({ alias: "my-app", to: relId, expectedCurrentId: liveId });
368
555
  * ```
556
+ *
557
+ * ⚠️ **A bare `rollback({ alias })` is a TOGGLE, not an idempotent step.**
558
+ * It means "restore the most recent READY release that is not the live one",
559
+ * which is a different release each time the alias moves. Run the same
560
+ * script twice and the second run rolls back the rollback — A → B → A → B,
561
+ * forever, with each hop a real alias swap. Nothing errors, because every
562
+ * individual call did exactly what it was asked.
563
+ *
564
+ * In anything re-runnable — a retry loop, a CI job, an agent that might redo
565
+ * a step — pin the outcome instead:
566
+ *
567
+ * - `to:` names the release you want live, so re-running is a no-op-ish
568
+ * convergence rather than a flip (a second run then reports the target
569
+ * already holds the alias instead of moving it);
570
+ * - `expectedCurrentId:` states what you expect to be live now, so a second
571
+ * run fails loudly instead of silently undoing the first.
369
572
  */
370
573
  async rollback(options) {
371
- const { alias, signal } = options;
372
- const retries = options.retries ?? 1;
574
+ const { alias, signal, expectedCurrentId } = options;
575
+ // A caller-supplied precondition and the CAS retry are mutually exclusive:
576
+ // retrying re-guards against whatever holds the alias NOW, which is the
577
+ // one thing `expectedCurrentId` exists to refuse.
578
+ const retries = expectedCurrentId === undefined ? (options.retries ?? 1) : 0;
373
579
  const requestOptions = signal ? { signal } : {};
374
580
  let targetId = options.to;
375
581
  for (let attempt = 0;; attempt++) {
@@ -381,6 +587,13 @@ export class Unfazed {
381
587
  if (!current) {
382
588
  throw new UnfazedNotFoundError(`unfazed: alias "${alias}" has no current release holding the alias`);
383
589
  }
590
+ // The caller's precondition, checked before anything is sent. Failing
591
+ // here rather than at the server keeps a violated expectation from
592
+ // costing an alias swap, and reports the holder we actually observed.
593
+ if (expectedCurrentId !== undefined && current.id !== expectedCurrentId) {
594
+ throw new UnfazedConflictError(`unfazed: alias "${alias}" is held by ${current.id}, not the expected ` +
595
+ `${expectedCurrentId} — it moved since you read it; re-read and decide again`, []);
596
+ }
384
597
  // Pin the target on the first pass so a retry refreshes only the guard.
385
598
  if (targetId === undefined) {
386
599
  const previous = releases.find((r) => r.id !== current.id && r.status === "READY");
@@ -421,7 +634,213 @@ export class Unfazed {
421
634
  }
422
635
  }
423
636
  }
637
+ /* --------------------------------------------------------------- agent runs
638
+ *
639
+ * A run is the past-tense counterpart to a deployment: boot a fresh VM off
640
+ * the box image, hand it a prompt, let a harness work for up to an hour,
641
+ * keep the transcript, meter what it cost, reap the VM. Deployments answer
642
+ * "what is running"; runs answer "what happened". Inside a run, `new
643
+ * Unfazed()` is already authenticated — the platform injects
644
+ * UNFZ_API_TOKEN / UNFZ_API_URL, at most the parent's scopes, revoked when
645
+ * the run settles.
646
+ */
647
+ /**
648
+ * Starts an agent run.
649
+ *
650
+ * Resolves once the VM has started (status RUNNING) — or, on a boot
651
+ * failure, once the run has already settled FAILED with `error` set. A
652
+ * boot failure is a normal return value here, not a thrown error, with or
653
+ * without `wait`.
654
+ *
655
+ * ```ts
656
+ * const run = await unfazed.createRun({
657
+ * workspaceId: ws.id,
658
+ * harness: "claude-code",
659
+ * prompt: "Fix the failing test in test/foo.test.ts and open a PR.",
660
+ * wait: true,
661
+ * });
662
+ * console.log(run.status, run.usage.mibSeconds);
663
+ * ```
664
+ *
665
+ * With `wait: true` this additionally polls a RUNNING run through to a
666
+ * terminal status — see {@link waitForRun} for exactly what "terminal"
667
+ * means and how a timeout is reported.
668
+ */
669
+ async createRun(options) {
670
+ const { workspaceId, prompt, harness, command, setup, skills, memoryMiB, maxSeconds, metadata, includeFiles, approval, mode, parentRunId, wait, waitOptions, signal, } = options;
671
+ const requestOptions = signal ? { signal } : {};
672
+ const data = await this.client.mutate(ops.CreateRunDocument, {
673
+ input: {
674
+ workspaceID: workspaceId,
675
+ ...(prompt === undefined ? {} : { prompt }),
676
+ ...(harness === undefined ? {} : { harness }),
677
+ ...(command === undefined ? {} : { command }),
678
+ ...(setup === undefined ? {} : { setup }),
679
+ ...(skills === undefined ? {} : { skills }),
680
+ ...(memoryMiB === undefined ? {} : { memoryMiB }),
681
+ ...(maxSeconds === undefined ? {} : { maxSeconds }),
682
+ ...(metadata === undefined ? {} : { metadata }),
683
+ ...(includeFiles === undefined ? {} : { includeFiles }),
684
+ ...(approval === undefined ? {} : { approval }),
685
+ ...(mode === undefined ? {} : { mode }),
686
+ ...(parentRunId === undefined ? {} : { parentRunID: parentRunId }),
687
+ },
688
+ }, requestOptions);
689
+ const run = data.createRun;
690
+ // Unlike createDeployment (always BUILDING), createRun can come back
691
+ // already terminal — a boot failure settles FAILED synchronously. Only
692
+ // poll when there is actually something to wait for.
693
+ if (!wait || isTerminalRunStatus(run.status))
694
+ return run;
695
+ return this.waitForRun(run.id, {
696
+ ...waitOptions,
697
+ ...(signal ? { signal } : {}),
698
+ });
699
+ }
700
+ /**
701
+ * Polls a run until it leaves QUEUED/RUNNING, throwing only on timeout.
702
+ *
703
+ * Terminal is defined negatively — anything that is not QUEUED or RUNNING —
704
+ * matching the platform's own status machine, so an unknown future status
705
+ * still ends the wait instead of looping forever. **SUCCEEDED, FAILED and
706
+ * CANCELLED are all returned normally**: a run that executed and exited
707
+ * non-zero is a completed answer (read `run.error` / `run.exitCode`), not
708
+ * an exception the way a FAILED deployment is. Only running out of time
709
+ * throws, as {@link UnfazedRunError} with `reason: "TIMEOUT"`.
710
+ */
711
+ async waitForRun(runId, waitOptions = {}) {
712
+ const resolved = resolveWaitOptions(waitOptions);
713
+ const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
714
+ let last = null;
715
+ await pollWithBackoff(async () => {
716
+ const data = await this.client.query(ops.PollRunDocument, { id: runId }, requestOptions);
717
+ const r = data.run;
718
+ last = r;
719
+ if (isTerminalRunStatus(r.status))
720
+ return { done: true, value: true };
721
+ return { done: false };
722
+ }, resolved, () => new UnfazedRunError("TIMEOUT", runId, last, `unfazed: run ${runId} still ${last?.status ?? "QUEUED"} after ${resolved.timeoutMs}ms`));
723
+ return this.getRun(runId, requestOptions);
724
+ }
725
+ /** Reads one run by id. Throws if it does not exist or is not visible to you. */
726
+ async getRun(runId, options) {
727
+ const data = await this.client.query(ops.GetRunDocument, { id: runId }, options);
728
+ return data.run;
729
+ }
730
+ /** Your agent runs, newest first, optionally filtered by workspace/status. */
731
+ async listRuns(filter = {}, options) {
732
+ const { workspaceId, status, limit, offset } = filter;
733
+ const data = await this.client.query(ops.ListRunsDocument, {
734
+ input: {
735
+ ...(workspaceId === undefined ? {} : { workspaceID: workspaceId }),
736
+ ...(status === undefined ? {} : { status }),
737
+ ...(limit === undefined ? {} : { limit }),
738
+ ...(offset === undefined ? {} : { offset }),
739
+ },
740
+ }, options);
741
+ return data.runs;
742
+ }
743
+ /**
744
+ * Stops a run: a QUEUED run is cancelled at once, a RUNNING run's VM is
745
+ * killed and the run settles CANCELLED shortly after (`waitForRun` to
746
+ * observe it). Idempotent on an already-CANCELLED run; a run that has
747
+ * already SUCCEEDED or FAILED cannot be cancelled and the server rejects
748
+ * the call (thrown as {@link UnfazedGraphQLError}) — check `status` first
749
+ * when that is a routine case for you.
750
+ */
751
+ async cancelRun(runId, options) {
752
+ const data = await this.client.mutate(ops.CancelRunDocument, { id: runId }, options);
753
+ return data.cancelRun;
754
+ }
755
+ /**
756
+ * Continues a FINISHED run with a new instruction: a new run in the same
757
+ * workspace that inherits the parent's harness (or command), setup, skills
758
+ * and approval, with the parent's task, outcome and the end of its
759
+ * transcript prepended to `prompt` as context. This is how a plan is
760
+ * approved — `createRun({ mode: "plan" })` writes the plan, `followUpRun`
761
+ * with the go-ahead executes it — and how work continues after a run
762
+ * exited. The new run carries `parentRunID` and `depth`; chains are capped
763
+ * server-side. A live run cannot be followed up (the server refuses):
764
+ * answer it or wait for it.
765
+ *
766
+ * ```ts
767
+ * const plan = await unfazed.createRun({ workspaceId, harness: "claude-code", prompt: "plan the migration", mode: "plan", wait: true });
768
+ * const done = await unfazed.followUpRun(plan.id, { prompt: "go ahead", wait: true });
769
+ * ```
770
+ */
771
+ async followUpRun(runId, options) {
772
+ const { prompt, mode, approval, maxSeconds, metadata, wait, waitOptions, signal } = options;
773
+ const requestOptions = signal ? { signal } : {};
774
+ const data = await this.client.mutate(ops.FollowUpRunDocument, {
775
+ input: {
776
+ runID: runId,
777
+ prompt,
778
+ ...(mode === undefined ? {} : { mode }),
779
+ ...(approval === undefined ? {} : { approval }),
780
+ ...(maxSeconds === undefined ? {} : { maxSeconds }),
781
+ ...(metadata === undefined ? {} : { metadata }),
782
+ },
783
+ }, requestOptions);
784
+ const run = data.followUpRun;
785
+ if (!wait || isTerminalRunStatus(run.status))
786
+ return run;
787
+ return this.waitForRun(run.id, { ...waitOptions, ...(signal ? { signal } : {}) });
788
+ }
789
+ /**
790
+ * Answers a BLOCKED run's pending question (`run.question`); the run
791
+ * resumes with the text. For a question with `options`, answer with one
792
+ * of them. Only a BLOCKED run accepts an answer — anything else is
793
+ * refused by the server.
794
+ *
795
+ * A run blocks when the agent inside calls `request_human_input`, or when
796
+ * it was created with `approval: "human"` and its harness asks permission
797
+ * for a guarded action. `waitForRun` keeps waiting through BLOCKED (it is
798
+ * not terminal); poll `getRun` and look at `question` to notice one.
799
+ */
800
+ async answerRun(runId, answer, options) {
801
+ const data = await this.client.mutate(ops.AnswerRunDocument, { id: runId, answer }, options);
802
+ return data.answerRun;
803
+ }
804
+ /** A run's own history, oldest first (started, cancel requested, settled). */
805
+ async runEvents(runId, input = {}, options) {
806
+ const data = await this.client.query(ops.RunEventsDocument, { runID: runId, limit: input.limit ?? null }, options);
807
+ return data.runEvents;
808
+ }
809
+ /**
810
+ * The live tail of a RUNNING run's console. Once the run has settled this
811
+ * comes back empty with `running: false` — read {@link runTranscript}
812
+ * instead, which is the stored, durable copy.
813
+ */
814
+ async runConsole(runId, input = {}, options) {
815
+ const data = await this.client.query(ops.RunConsoleDocument, { id: runId, tailBytes: input.tailBytes ?? null }, options);
816
+ return data.runConsole;
817
+ }
818
+ /**
819
+ * The bounded end of a finished run's stored transcript (secrets
820
+ * redacted). `available` is false until the run settles and the transcript
821
+ * is copied out of the VM's console file — check `run.transcriptAvailable`
822
+ * or poll this after {@link waitForRun} resolves.
823
+ */
824
+ async runTranscript(runId, input = {}, options) {
825
+ const data = await this.client.query(ops.RunTranscriptDocument, { id: runId, tailBytes: input.tailBytes ?? null }, options);
826
+ return data.runTranscript;
827
+ }
424
828
  /* -------------------------------------------------------------- internals */
829
+ /**
830
+ * Finds a visible workspace by name, the way the server compares names.
831
+ *
832
+ * The server rejects duplicates with Go's `strings.EqualFold`, so matching
833
+ * must be case-insensitive here too or `ensureWorkspace` would try to create
834
+ * a "new" workspace the server considers a duplicate. An exact-case hit wins
835
+ * over a folded one, which only matters if a name ever slipped past the
836
+ * uniqueness check.
837
+ */
838
+ async #findWorkspaceByName(name, options) {
839
+ const workspaces = await this.listWorkspaces(options);
840
+ const folded = name.toLowerCase();
841
+ return (workspaces.find((w) => w.name === name) ??
842
+ workspaces.find((w) => w.name.toLowerCase() === folded));
843
+ }
425
844
  async #writeBatch(workspaceId, batch, signal) {
426
845
  const varDefs = ["$workspaceID: ID!"];
427
846
  const fields = [];
@@ -445,6 +864,14 @@ export class Unfazed {
445
864
  }
446
865
  const FILE_SELECTION = "path sizeBytes size sha256 updatedAt";
447
866
  const ENV_SELECTION = "name value secret alias inherited masked updatedAt";
867
+ /**
868
+ * Terminal = anything but QUEUED or RUNNING, defined negatively exactly like
869
+ * the platform's own `IsTerminalRunStatus` — so a future status this SDK does
870
+ * not know about yet still ends a wait instead of polling forever.
871
+ */
872
+ function isTerminalRunStatus(status) {
873
+ return status !== "QUEUED" && status !== "RUNNING";
874
+ }
448
875
  function normalizeFile(path, content) {
449
876
  if (typeof content === "string")
450
877
  return { path, kind: "text", content };