@alexkroman1/aai-cli 10.0.1 → 11.0.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 (45) hide show
  1. package/dist/{_bundler-B31MqYaD.mjs → _bundler-C31rlcYz.mjs} +1 -1
  2. package/dist/{_dev-server-Bo5dSBzQ.mjs → _dev-server-DWvkNoIS.mjs} +3 -3
  3. package/dist/{_vite-env-BNveawd1.mjs → _vite-env-D9Qj6KPX.mjs} +4 -2
  4. package/dist/_workflow-determinism-f9J-5Zlj.mjs +206 -0
  5. package/dist/_workflow-determinism.d.ts +118 -0
  6. package/dist/{build-DKC2e9o_.mjs → build-BLlLOrng.mjs} +4 -2
  7. package/dist/cli.mjs +372 -353
  8. package/dist/{client-bundler-CWnG42cU.mjs → client-bundler-j5c46x0G.mjs} +3 -2
  9. package/dist/client-bundler.mjs +1 -1
  10. package/dist/{deploy-CchW5pmw.mjs → deploy-BzZ-r4iM.mjs} +6 -4
  11. package/dist/{dev-BfVl1uBA.mjs → dev-BDykFvcc.mjs} +1 -1
  12. package/dist/{eval-DwNwdqmS.mjs → eval-0qEOdm0l.mjs} +1 -1
  13. package/dist/{init-BRfoc2EK.mjs → init-BtJI306C.mjs} +1 -1
  14. package/dist/{login-BBuM1sxH.mjs → login-DeUETobb.mjs} +4 -1
  15. package/dist/scaffold/CLAUDE.md +190 -11
  16. package/dist/scaffold/package.json +4 -4
  17. package/dist/{studio-wVWNLREn.mjs → studio-BxAS_FQQ.mjs} +4 -1
  18. package/dist/templates/call-audit/agent.test.ts +105 -26
  19. package/dist/templates/call-audit/workflows/audit.ts +9 -26
  20. package/dist/templates/dispatch-center/client.tsx +36 -2
  21. package/dist/templates/infocom-adventure/client.tsx +37 -9
  22. package/dist/templates/link-digest/agent.eval.test.ts +1 -1
  23. package/dist/templates/link-digest/agent.test.ts +164 -8
  24. package/dist/templates/link-digest/workflows/digest.ts +63 -20
  25. package/dist/templates/podcast-digest/agent.eval.test.ts +7 -4
  26. package/dist/templates/podcast-digest/agent.test.ts +265 -24
  27. package/dist/templates/podcast-digest/workflows/digest.ts +2 -2
  28. package/dist/templates/podcast-digest/workflows/feeds.ts +78 -73
  29. package/dist/templates/recap-workflow/agent.test.ts +206 -20
  30. package/dist/templates/recap-workflow/workflows/recap.ts +17 -14
  31. package/dist/templates/redline/agent.test.ts +137 -11
  32. package/dist/templates/research-workflow/agent.eval.test.ts +1 -1
  33. package/dist/templates/research-workflow/agent.test.ts +131 -9
  34. package/dist/templates/research-workflow/workflows/research.ts +1 -1
  35. package/dist/templates/retail/client.tsx +30 -2
  36. package/dist/templates/spoken-summary/agent.eval.test.ts +4 -1
  37. package/dist/templates/spoken-summary/agent.test.ts +117 -8
  38. package/dist/templates/spoken-summary/workflows/summarize.ts +1 -1
  39. package/dist/templates/transcription-workflow/agent.test.ts +127 -13
  40. package/dist/templates/transcription-workflow/workflows/batch.ts +6 -6
  41. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -3
  42. package/dist/templates/transcription-workflow/workflows/transcribe.ts +4 -23
  43. package/dist/{test-CvwgeVSQ.mjs → test-CiLab-AA.mjs} +2 -1
  44. package/dist/worker-bundler.mjs +1 -1
  45. package/package.json +25 -24
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { o as errorMessage$1, s as fileExists } from "./_utils-B8QmtFhK.mjs";
3
- import { n as withPreservedNodeEnv, t as DEDUPED_PEERS } from "./_vite-env-BNveawd1.mjs";
3
+ import { n as withPreservedNodeEnv, t as DEDUPED_PEERS } from "./_vite-env-D9Qj6KPX.mjs";
4
4
  import { existsSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { omitUndefined } from "@alexkroman1/aai/utils";
@@ -82,7 +82,8 @@ const DEFAULT_OUT_DIR = ".aai/client";
82
82
  * API and not covered by semver.
83
83
  */
84
84
  async function buildClient(cwd, opts = {}) {
85
- if (!await fileExists(path.join(cwd, "client.tsx"))) return {};
85
+ const clientEntry = path.join(cwd, "client.tsx");
86
+ if (!await fileExists(clientEntry)) return {};
86
87
  const outDir = opts.outDir ?? DEFAULT_OUT_DIR;
87
88
  const clientDir = path.join(cwd, outDir);
88
89
  let cleanupHtml = () => {};
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import { t as buildClient } from "./client-bundler-CWnG42cU.mjs";
2
+ import { t as buildClient } from "./client-bundler-j5c46x0G.mjs";
3
3
  export { buildClient };
@@ -3,10 +3,11 @@ import { a as ok } from "./_output-CKkmWs7i.mjs";
3
3
  import { n as log, r as notify, t as fmtUrl } from "./_ui-u7T4YooX.mjs";
4
4
  import { o as errorMessage$1 } from "./_utils-B8QmtFhK.mjs";
5
5
  import { s as updateProjectConfig } from "./_config-CmJOFsAP.mjs";
6
- import { i as evalWorkerConfig, t as buildAgentBundle } from "./_bundler-B31MqYaD.mjs";
6
+ import { i as evalWorkerConfig, t as buildAgentBundle } from "./_bundler-C31rlcYz.mjs";
7
7
  import { t as resolveServerEnv } from "./_server-common-6e0QI4mq.mjs";
8
8
  import { a as resolveDeployTarget } from "./_agent-BTwZJu4P.mjs";
9
9
  import { assertTypechecks } from "./_typecheck-gate-BXvUNnfs.mjs";
10
+ import { n as scanWorkflowDeterminism, t as determinismWarnings } from "./_workflow-determinism-f9J-5Zlj.mjs";
10
11
  import { d as apiTestSeam, f as checkedResponse, i as projectNameFromDir, u as apiRequest } from "./_studio-B1waXMuR.mjs";
11
12
  import { isRecord, omitUndefined } from "@alexkroman1/aai/utils";
12
13
  import { gzipSync } from "node:zlib";
@@ -19,7 +20,7 @@ async function runDeploy(opts) {
19
20
  worker: opts.bundle.worker,
20
21
  clientFiles: opts.bundle.clientFiles
21
22
  }));
22
- return checkedResponse(await apiRequest(`${opts.url}/deploy`, {
23
+ const data = await apiRequest(`${opts.url}/deploy`, {
23
24
  method: "POST",
24
25
  body,
25
26
  headers: {
@@ -31,7 +32,8 @@ async function runDeploy(opts) {
31
32
  hints: { 413: "Your bundle is too large. Try reducing dependencies or splitting your agent." },
32
33
  ...opts.slug ? {} : { retry: 0 },
33
34
  ...apiTestSeam(opts)
34
- }), (value) => isRecord(value) && typeof value.slug === "string", `the deploy route at ${opts.url}`);
35
+ });
36
+ return checkedResponse(data, (value) => isRecord(value) && typeof value.slug === "string", `the deploy route at ${opts.url}`);
35
37
  }
36
38
  //#endregion
37
39
  //#region deploy.ts
@@ -49,7 +51,7 @@ async function executeDeploy(opts) {
49
51
  const { missingCredentialMessage, missingCredentials } = await preflightModule;
50
52
  const config = await evalWorkerConfig(bundle.worker);
51
53
  const missing = config ? missingCredentials(config, uploadEnv) : [];
52
- const warnings = missing.length > 0 ? [missingCredentialMessage(missing)] : [];
54
+ const warnings = [...missing.length > 0 ? [missingCredentialMessage(missing)] : [], ...determinismWarnings(await scanWorkflowDeterminism(cwd))];
53
55
  for (const warning of warnings) notify("warn", warning);
54
56
  log.step(`Deploying${slug ? ` ${slug}` : ""}…`);
55
57
  const deployed = await runDeploy({
@@ -12,7 +12,7 @@ import { styleText } from "node:util";
12
12
  async function executeDev(opts) {
13
13
  const port = parsePort(opts.port);
14
14
  const agentName = path.basename(path.resolve(opts.cwd));
15
- const { startDevServer } = await import("./_dev-server-Bo5dSBzQ.mjs");
15
+ const { startDevServer } = await import("./_dev-server-DWvkNoIS.mjs");
16
16
  let cleanup;
17
17
  let shuttingDown = false;
18
18
  const onSignal = () => {
@@ -2,7 +2,7 @@
2
2
  import { a as ok, n as fail } from "./_output-CKkmWs7i.mjs";
3
3
  import { n as log } from "./_ui-u7T4YooX.mjs";
4
4
  import { t as resolveServerEnv } from "./_server-common-6e0QI4mq.mjs";
5
- import { classifyVitestError, runVitest } from "./test-CvwgeVSQ.mjs";
5
+ import { classifyVitestError, runVitest } from "./test-CiLab-AA.mjs";
6
6
  //#region eval.ts
7
7
  /**
8
8
  * `aai eval` — run the agent's behaviour evals via vitest.
@@ -106,7 +106,7 @@ function collectWarnings() {
106
106
  }
107
107
  /** Publish after init and return deploy metadata if successful. */
108
108
  async function tryPublish(cwd, server, warn) {
109
- const { executePublish } = await import("./studio-wVWNLREn.mjs");
109
+ const { executePublish } = await import("./studio-BxAS_FQQ.mjs");
110
110
  try {
111
111
  const result = await executePublish({
112
112
  cwd,
@@ -32,7 +32,10 @@ const LINK_POLL_INTERVAL_MS = 2e3;
32
32
  const LINK_TIMEOUT_MS = 3e5;
33
33
  async function jsonBody(res, what) {
34
34
  const body = await res.json().catch(() => null);
35
- if (!res.ok) throw new CliError("login_failed", `${what} failed: ${body?.error ?? body?.msg ?? `HTTP ${res.status}`}`);
35
+ if (!res.ok) {
36
+ const detail = body?.error ?? body?.msg ?? `HTTP ${res.status}`;
37
+ throw new CliError("login_failed", `${what} failed: ${detail}`);
38
+ }
36
39
  if (body === null) throw new CliError("login_failed", `${what} returned an invalid response`);
37
40
  return body;
38
41
  }
@@ -428,7 +428,10 @@ export async function digestFlow(input: { url: string }, ctx: WorkflowCtx) {
428
428
 
429
429
  // Suspended, not blocked: the container is free to exit here and the run
430
430
  // resumes when it comes due. Six hours works the same as ten seconds.
431
- await ctx.sleep(10_000);
431
+ //
432
+ // The first argument NAMES the wait, exactly as a step's name does, and for
433
+ // the same reason: it is the wait's identity in the journal.
434
+ await ctx.sleep("settle", 10_000);
432
435
 
433
436
  const filedAt = await ctx.step("file", () => file(digest));
434
437
  return { ...digest, filedAt };
@@ -455,8 +458,10 @@ them:
455
458
 
456
459
  - **The body replays from the top on every resume**, so it holds no live handle
457
460
  and makes no undurable decision — no `Date.now()`, no `Math.random()`, no
458
- `crypto.randomUUID()`, no `fetch`. Those go inside a `ctx.step`, whose result
459
- is journaled and returned unchanged on replay.
461
+ `crypto.randomUUID()`, no `fetch`. The three commonest have methods of their
462
+ own (`ctx.now()`, `ctx.random()`, `ctx.uuid()` see below); anything else goes
463
+ inside a `ctx.step`, whose result is journaled and returned unchanged on
464
+ replay.
460
465
  - **A step's arguments and return value cross a queue**, so they must be
461
466
  JSON-shaped and small. Put bytes in storage and pass the key.
462
467
  - **A step gets no tool context.** There is no `ctx.db` and no `ctx.generate`
@@ -481,6 +486,66 @@ const digest = await ctx.step("summarize", () => summarize(input.url), {
481
486
  });
482
487
  ```
483
488
 
489
+ **And a step body can read which attempt it is on**, so a step may degrade rather
490
+ than fail — a smaller model on the last try beats a failed run:
491
+
492
+ ```ts
493
+ import { stepInfo } from "@alexkroman1/aai/step";
494
+
495
+ declare function callModel(url: string, model: string): Promise<string>;
496
+
497
+ export async function summarize(url: string) {
498
+ const step = stepInfo();
499
+ // `undefined` outside a run — a spec calling this directly — which reads as
500
+ // "not retrying", the same branch a first attempt takes.
501
+ const model = step?.isLastAttempt === true ? "small" : "large";
502
+ return await callModel(url, model);
503
+ }
504
+ ```
505
+
506
+ Read `isLastAttempt` rather than comparing `attempt` against a number you have
507
+ written down: the ceiling lives at the `ctx.step` call site, and a body that
508
+ restates it degrades early on every run once the two disagree — silently, since
509
+ it still returns an answer. `stubStepInfo` from `@alexkroman1/aai/testing` is how
510
+ a test reaches the retry branch.
511
+
512
+ ### A clock, a random number and a uuid: `ctx.now`, `ctx.random`, `ctx.uuid`
513
+
514
+ The three undurable reads a body most often wants, each journaled — read once at
515
+ the first reach, and the same value on every later walk:
516
+
517
+ ```ts
518
+ import type { WorkflowCtx } from "@alexkroman1/aai";
519
+
520
+ declare function charge(amount: number, idempotencyKey: string, jitter: number): Promise<void>;
521
+
522
+ export async function chargeFlow(input: { amount: number }, ctx: WorkflowCtx) {
523
+ const startedAt = await ctx.now(); // epoch ms, decided once
524
+ const idempotencyKey = await ctx.uuid(); // still the same id after a crash
525
+ const jitter = await ctx.random(); // one float in [0, 1), journaled per call
526
+
527
+ await ctx.step("charge", () => charge(input.amount, idempotencyKey, jitter));
528
+ return { elapsedMs: (await ctx.now()) - startedAt };
529
+ }
530
+ ```
531
+
532
+ `ctx.uuid()` is what an idempotency key for a downstream API wants: minted once,
533
+ and the same value after a resume, so a retried request is recognisably the same
534
+ request rather than a second one. `ctx.random()` draws one float per CALL, so a
535
+ loop is correct as written; a BULK draw belongs in a step
536
+ (`ctx.step("jitter", () => Array.from({ length: 1000 }, Math.random))`), which is
537
+ one journal entry instead of a thousand.
538
+
539
+ Two rules:
540
+
541
+ - **Call them from the BODY, never inside a `ctx.step`** — the engine refuses one
542
+ there and the message names the fix. Inside a step there is nothing to fix: a
543
+ step's internals are not replayed, only its result, so a plain `Date.now()` in
544
+ a step body is already durable and is what to write.
545
+ - **A `ctx.uuid()` is not a hook TOKEN.** `ctx.waitFor`'s token has to be
546
+ DERIVED from the run's own input, because whoever signals is usually a tool and
547
+ a tool cannot see the body's local variables. See below.
548
+
484
549
  ### Waiting: `ctx.sleep` and `ctx.waitFor`
485
550
 
486
551
  Both SUSPEND the run — the body stops, the container is free, and the engine
@@ -493,8 +558,8 @@ process. Under `aai dev` without a `DATABASE_URL` the store is memory, so a wait
493
558
  lives only as long as the dev server. The boot line reports which one is in play.
494
559
 
495
560
  ```ts no-check
496
- // A duration in milliseconds, or an absolute Date.
497
- await ctx.sleep(6 * 60 * 60 * 1000, { correlationId: "review-window" });
561
+ // A label, then a duration in milliseconds or an absolute Date.
562
+ await ctx.sleep("review-window", 6 * 60 * 60 * 1000, { correlationId: "review" });
498
563
 
499
564
  // Until somebody outside the run answers, via `ctx.workflows.signal(token, …)`
500
565
  // from a tool, or by a delivery to `publicWebhookUrl` — both hops reach the
@@ -505,8 +570,15 @@ const approval = await ctx.waitFor<{ approved: boolean }>(approvalToken(input.id
505
570
  if (approval === undefined) return { published: false, reason: "nobody approved" };
506
571
  ```
507
572
 
508
- Four things worth knowing:
573
+ Five things worth knowing:
509
574
 
575
+ - **A wait's NAME is its identity, exactly like a step's.** A sleep's `label` and
576
+ a `waitFor`'s token are what the journal keys the wait on
577
+ (`sleep!<label>#<occurrence>`, `hook!<token>#<occurrence>`), so make a label a
578
+ string literal, give two call sites two labels, and let a loop reuse one — the
579
+ occurrence count separates the iterations. This is what makes a wait behind an
580
+ `if` safe: the body can reach a different NUMBER of waits on two walks and each
581
+ one still finds its own record.
510
582
  - **A hook's token must be DERIVED, not random.** Whoever signals is usually a
511
583
  tool, and a tool cannot see the body's local variables — so export one function
512
584
  that computes the token from the run's own input and import it in both places.
@@ -516,10 +588,15 @@ Four things worth knowing:
516
588
  - **`timeoutMs` resolves `undefined` when the window closes unanswered.** A
517
589
  closing window is an outcome to branch on, not a failure, and the engine closes
518
590
  the hook as it shuts so a late answer cannot change what already happened.
519
- - **Do NOT race the two.** `Promise.race([ctx.waitFor(t), ctx.sleep(ms)])` does
520
- not work: both suspend, and a suspend unwinds the stack, so the race stops the
521
- body before the other side has been reached. That is why the deadline is a
522
- parameter.
591
+ - **Racing the two WORKS, and is still not how to put a deadline on a wait.** A
592
+ wait no longer unwinds the stack it hands back a promise that never settles
593
+ so the body reaches every wait a `race` or an `all` puts in front of it and
594
+ the run suspends once, on the earliest deadline among them. Reach for a race
595
+ when the two waits are genuinely independent (a review window beside a retry
596
+ backoff). For a deadline ON a wait, use `timeoutMs`: it is journaled WITH the
597
+ hook, so one decision fixes the window, and its timeout arm CLOSES the hook
598
+ before the body continues — a race has no such moment, and a signal landing
599
+ just after it would make the next replay answer a window this one timed out.
523
600
  - **`ctx.workflows.wakeUp(runId, { correlationIds: [id] })`** ends a sleep early,
524
601
  which is how a "send it now" tool cuts a scheduled wait short. Naming no ids
525
602
  wakes every outstanding SLEEP and deliberately not a `waitFor` deadline, so
@@ -574,6 +651,75 @@ trap somebody has already paid for:
574
651
  pnpm dev` is what makes it reachable. Until you set it, treat local runs as
575
652
  coverage of the backstop only.
576
653
 
654
+ ### Testing a workflow body
655
+
656
+ Steps are ordinary exported functions, so a spec imports and calls them. The
657
+ BODY needs an engine, and there are two, for two different questions.
658
+
659
+ **"What did the body ask for?"** — `createWorkflowCtx` from
660
+ `@alexkroman1/aai/testing`. It runs the steps and records the names, the retry
661
+ policies and the sleeps, over one walk with no journal. Nothing replays, so a
662
+ spec built on it must not claim to test durability.
663
+
664
+ ```ts no-check
665
+ import { createWorkflowCtx } from "@alexkroman1/aai/testing";
666
+
667
+ const ctx = createWorkflowCtx({ runSteps: false });
668
+ await digestFlow({ url: "https://example.com/a" }, ctx);
669
+
670
+ expect(ctx.steps.map((s) => s.name)).toEqual(["fetchArticle", "summarize", "file"]);
671
+ expect(ctx.steps.find((s) => s.name === "summarize")?.maxAttempts).toBe(6);
672
+ expect(ctx.slept).toEqual([{ until: 10_000 }]);
673
+ ```
674
+
675
+ **"Is the run actually durable?"** — `runWorkflow` from
676
+ `@alexkroman1/aai-runtime/testing`. It starts the declared workflow on the real
677
+ replay engine over an in-memory journal, one delivery at a time, with a
678
+ suspension RECORDED rather than waited out. So a body that sleeps six hours
679
+ costs a spec nothing, and the run really suspends, really resumes off its
680
+ journal, and really survives a restart.
681
+
682
+ ```ts no-check
683
+ import { runWorkflow } from "@alexkroman1/aai-runtime/testing";
684
+ import { digest } from "./agent.ts";
685
+
686
+ // Parks on the wait instead of blocking, with the work before it journaled.
687
+ const run = await runWorkflow(digest, { url: "https://example.com/a" }, {
688
+ name: "digest",
689
+ });
690
+ expect(run.status).toBe("running");
691
+ expect(run.wakeAt).toBeGreaterThan(Date.now());
692
+ expect(run.steps.map((s) => s.name)).toEqual(["fetchArticle", "summarize"]);
693
+
694
+ // Ends the wait the way `ctx.workflows.wakeUp` does, and the body continues.
695
+ await run.advanceSleep();
696
+ expect(run.status).toBe("completed");
697
+ expect(run.deliveries).toBe(2);
698
+ ```
699
+
700
+ Three more things it can do, each the thing a durable body is written for:
701
+
702
+ - `run.signal(token, payload)` answers a `ctx.waitFor`, so an approval gate is
703
+ testable without a second process.
704
+ - `{ crashAt: "summarize" }` kills the first delivery that reaches that step,
705
+ before its body runs — a worker that died mid-run. `await run.restart()` then
706
+ boots a fresh engine over the same journal, and only the step that never
707
+ settled runs again.
708
+ - `{ journal }` shares one store between runs, so a spec can assert what a
709
+ second run sees.
710
+
711
+ Stub the steps' collaborators at the seams they really use — a step's HTTP goes
712
+ through the published `stepFetch` slot, so a model call and a page fetch are BOTH
713
+ answered there. `stubGatewayRoute` composes the two:
714
+
715
+ ```ts no-check
716
+ import { stubGatewayRoute } from "@alexkroman1/aai/testing";
717
+ import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
718
+
719
+ const model = stubGatewayRoute('{"headline":"H","points":["a"]}');
720
+ installStubStepFetch((request) => model.route(request) ?? { body: PAGE_HTML });
721
+ ```
722
+
577
723
  ### A step's env, and calling a model from one
578
724
 
579
725
  A step has no `ctx`, so the two things tool code takes for granted come from
@@ -1712,7 +1858,7 @@ safe from a `workflows/*.ts` module and from a browser bundle:
1712
1858
  | `formatBytes`, `formatDuration`, `countWords`, `plural` | Narration. Each returns ONE fixed shape, so a step's progress line and the page rendering the same run cannot disagree — they did, one template printing `1:04:09` from its workflow and `64:09` from its page |
1713
1859
  | `pushCapped(list, item, max)` | An append that keeps the last N, for a log a session accumulates |
1714
1860
  | `isRecord(x)`, `omitUndefined(obj)` | The object guard and the spread-free way to drop undefined fields |
1715
- | `decodeHtmlEntities(text)` | Scraped text on its way to a model |
1861
+ | `decodeHtmlEntities(text)` | Six entities, no dependency. Enough for a `client.tsx`; for a page or a feed see `/html` below |
1716
1862
  | `createKeyedLock()` / `withLock(lock, key, work)` | Serializing async work per key |
1717
1863
 
1718
1864
  **`createKeyedLock` is the one an agent most needs and least expects to.** The
@@ -1722,6 +1868,39 @@ that case — `slot.update`'s window is synchronous — so reach for the lock wh
1722
1868
  the thing being mutated is outside the session. `withLock` takes an optional
1723
1869
  acquire deadline and throws `KeyedLockTimeoutError` when it runs out.
1724
1870
 
1871
+ ## Reading a page or a feed — `@alexkroman1/aai/html`
1872
+
1873
+ A step that fetches somebody else's markup gets a real parse rather than a
1874
+ regex. Node-only (it pulls two parsers), so import it from `workflows/*.ts` or a
1875
+ tool, never from `client.tsx`:
1876
+
1877
+ ```ts
1878
+ import { htmlToText, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
1879
+
1880
+ declare const html: string;
1881
+ declare const xml: string;
1882
+
1883
+ // A page, reduced to the prose worth putting in a prompt. `<script>` and
1884
+ // `<style>` bodies never survive, and `maxChars` caps what crosses the wire.
1885
+ const article = htmlToText(html, { maxChars: 20_000 });
1886
+
1887
+ // `og:title` when the page declares one, else its `<title>` element.
1888
+ const { title, description, feedUrls } = pageMetadata(html);
1889
+
1890
+ // RSS, Atom and RDF alike. `published` is ISO whatever the feed wrote, and
1891
+ // titles come back as TEXT — feeds wrap HTML in CDATA as a matter of course.
1892
+ const feed = parseFeed(xml);
1893
+ const episodes = feed?.items.filter((item) => item.enclosureUrl !== undefined) ?? [];
1894
+ ```
1895
+
1896
+ **Reach for this rather than writing the patterns.** Both are cheap to get
1897
+ wrong in ways that only show up on real pages: `<[^>]+>` cuts a tag whose
1898
+ attribute contains a `>`, `<script[^>]*>[\s\S]*?<\/script>` leaves the whole
1899
+ script in your prompt when the page was truncated mid-tag, and
1900
+ `indexOf("<title>")` finds an entry's title rather than a channel's. The
1901
+ `link-digest` and `podcast-digest` templates each shipped a version of those
1902
+ before this subpath existed.
1903
+
1725
1904
  ## Persisting data — bring your own client
1726
1905
 
1727
1906
  **There is no `ctx.db`.** It was a SQL handle on the tool context, backed first
@@ -14,9 +14,9 @@
14
14
  "publish:agent": "aai publish"
15
15
  },
16
16
  "dependencies": {
17
- "@alexkroman1/aai": "^10.0.1",
18
- "@alexkroman1/aai-runtime": "^10.0.1",
19
- "@alexkroman1/aai-ui": "^10.0.1",
17
+ "@alexkroman1/aai": "^11.0.0",
18
+ "@alexkroman1/aai-runtime": "^11.0.0",
19
+ "@alexkroman1/aai-ui": "^11.0.0",
20
20
  "react": "^19.2.8",
21
21
  "react-dom": "^19.2.8",
22
22
  "tailwindcss": "^4.0.0",
@@ -24,7 +24,7 @@
24
24
  "zod": "^4.4.3"
25
25
  },
26
26
  "devDependencies": {
27
- "@alexkroman1/aai-cli": "^10.0.1",
27
+ "@alexkroman1/aai-cli": "^11.0.0",
28
28
  "@tailwindcss/vite": "^4.3.3",
29
29
  "@types/node": "^26.2.0",
30
30
  "@types/react": "^19.2.18",
@@ -97,7 +97,10 @@ async function pushProject(opts) {
97
97
  const { files, warnings } = await collectSourceFiles(opts.cwd);
98
98
  for (const warning of warnings) log.warn(warning);
99
99
  if (Object.keys(files).length === 0) throw new Error("Nothing to push — this directory has no project files.");
100
- if (!files["agent.ts"] && existsSync(path.join(opts.cwd, "agent.ts"))) throw new CliError("entry_not_synced", warnings.find((w) => w.startsWith("agent.ts ")) ?? "agent.ts exists locally but was not synced.", "The entry file must sync to deploy — reduce its size or fix its encoding.");
100
+ if (!files["agent.ts"] && existsSync(path.join(opts.cwd, "agent.ts"))) {
101
+ const reason = warnings.find((w) => w.startsWith("agent.ts "));
102
+ throw new CliError("entry_not_synced", reason ?? "agent.ts exists locally but was not synced.", "The entry file must sync to deploy — reduce its size or fix its encoding.");
103
+ }
101
104
  let project = config?.studioProject;
102
105
  let baseHash = config?.studioSourceHash;
103
106
  let slug = config?.slug;
@@ -21,12 +21,18 @@
21
21
  * argv they will run, and the classification of a failure — which is where a
22
22
  * mistake is silent, since a `timeout` called fatal is a run that gives up on work
23
23
  * that would have finished.
24
+ *
25
+ * The last block adds a third: the ENGINE's half of that classification, on a
26
+ * real run. `runWorkflow` starts this desk's declared workflow on the replay
27
+ * engine, and with no ffmpeg the first step fails fatally — which is exactly the
28
+ * case worth asserting, because `FatalError` exists to stop the engine spending
29
+ * the six attempts this call site asks for on a file that will never convert.
24
30
  */
25
31
 
26
32
  import { readdir } from "node:fs/promises";
27
33
  import { tmpdir } from "node:os";
28
34
  import { FatalError, RetryableError } from "@alexkroman1/aai/step-errors";
29
- import { createWorkflowCtx, stubSpeech } from "@alexkroman1/aai/testing";
35
+ import { createWorkflowCtx, stubSpeech, WORKFLOW_CTX_NOW } from "@alexkroman1/aai/testing";
30
36
  import {
31
37
  installStubGateway,
32
38
  installStubReporter,
@@ -34,12 +40,12 @@ import {
34
40
  installStubTranscribe,
35
41
  installStubUploads,
36
42
  } from "@alexkroman1/aai/testing/vitest";
43
+ import { runWorkflow } from "@alexkroman1/aai-runtime/testing";
37
44
  import { beforeEach, describe, expect, test, vi } from "vitest";
38
45
  import agentDef, { audit } from "./agent.ts";
39
46
  import {
40
47
  auditFlow,
41
48
  joinSegments,
42
- now,
43
49
  SEGMENT_CONCURRENCY,
44
50
  transcribeSegment,
45
51
  } from "./workflows/audit.ts";
@@ -732,20 +738,6 @@ describe("classifying a failure", () => {
732
738
  });
733
739
  });
734
740
 
735
- describe("the run's clock", () => {
736
- test("is a STEP, so a replay does not re-measure it", async () => {
737
- // A `Date.now()` in the body returns a different value on every replay, and
738
- // every duration derived from it would be a different duration.
739
- vi.useFakeTimers();
740
- try {
741
- vi.setSystemTime(1_700_000_000_000);
742
- await expect(now()).resolves.toBe(1_700_000_000_000);
743
- } finally {
744
- vi.useRealTimers();
745
- }
746
- });
747
- });
748
-
749
741
  describe("the mastered narration", () => {
750
742
  test("is not driven here, and the spec says why rather than pretending", () => {
751
743
  // `narrate` speaks, writes a temp file, spawns ffmpeg and stores the result. A
@@ -821,6 +813,18 @@ describe("the ffmpeg steps, up to the spawn", () => {
821
813
  });
822
814
 
823
815
  describe("the body's step policy", () => {
816
+ /**
817
+ * A `ctx.now` that answers each reach from `samples`, in order.
818
+ *
819
+ * The last value repeats rather than running out: a spec naming two reads
820
+ * should fail on the DURATION if the body grows a third, not on an
821
+ * `undefined` from the fake.
822
+ */
823
+ const clockReads = (...samples: readonly number[]) => {
824
+ let reach = 0;
825
+ return () => samples[Math.min(reach++, samples.length - 1)] ?? 0;
826
+ };
827
+
824
828
  /**
825
829
  * A `ctx` that walks the whole body without running a single step.
826
830
  *
@@ -831,9 +835,13 @@ describe("the body's step policy", () => {
831
835
  const walkedCtx = () =>
832
836
  createWorkflowCtx({
833
837
  runSteps: false,
838
+ // The body reads the clock at both ends with `ctx.now()`, which the engine
839
+ // journals under `now!0` and `now!1`. A PRODUCER here rather than a fixed
840
+ // number, so the two reaches differ and `elapsedMs` is assertable — based
841
+ // on `WORKFLOW_CTX_NOW`, the instant the fake otherwise freezes at, rather
842
+ // than on a second arbitrary epoch nobody can relate to the first.
843
+ now: clockReads(WORKFLOW_CTX_NOW, WORKFLOW_CTX_NOW + 3000),
834
844
  results: {
835
- clockStart: 1000,
836
- clockEnd: 4000,
837
845
  ingestRecording: {
838
846
  audio: "upl_pcm",
839
847
  source: "call.wav",
@@ -867,23 +875,94 @@ describe("the body's step policy", () => {
867
875
  // other half of the claim: a raised budget is a decision about ONE step.
868
876
  // Asserted as PRESENT-with-no-budget rather than as `get(…) === undefined`,
869
877
  // which a step the body never reached at all would also satisfy.
870
- for (const name of ["clockStart", "summarize"]) {
878
+ for (const name of ["narrate", "summarize"]) {
871
879
  expect(budgets.has(name)).toBe(true);
872
880
  expect(budgets.get(name)).toBeUndefined();
873
881
  }
874
882
  });
875
883
 
876
- test("reads the clock at each end under its own name, so a run's history is legible", async () => {
877
- // `(name, occurrence)` would tell two `now` calls apart on its own
878
- // (`now#0`, `now#1`); distinct names are for the person reading the history.
884
+ test("reads the clock with `ctx.now()` at each end, and subtracts in the body", async () => {
885
+ // Two reaches of one method, keyed `now!0` and `now!1` and journaled
886
+ // separately by the engine. This used to be two NAMED steps over an exported
887
+ // one-line clock read, and the names existed only so a person reading the
888
+ // run's history could tell the ends apart; the affordance journals the read
889
+ // itself. What is left to assert is that the body subtracts two journaled
890
+ // values rather than re-reading a clock, which is what makes a replay report
891
+ // the same elapsed.
879
892
  const ctx = walkedCtx();
880
893
 
881
894
  const output = await auditFlow({ recording: UPLOAD_ID }, ctx);
882
895
 
883
- expect(ctx.steps[0]?.name).toBe("clockStart");
884
- expect(ctx.steps.at(-1)?.name).toBe("clockEnd");
885
- // Subtracted in the BODY from two journaled values, so a replay reports the
886
- // same elapsed rather than re-reading a clock.
887
896
  expect(output.elapsedMs).toBe(3000);
897
+ // And the clock is no longer a step, which is the other half of the claim: a
898
+ // name here would mean the migration left one behind.
899
+ expect(ctx.steps.map((step) => step.name)).not.toContain("clockStart");
900
+ });
901
+ });
902
+
903
+ /**
904
+ * `auditFlow` on the real replay engine — as far as this tier can take it.
905
+ *
906
+ * The block above drives the body through `createWorkflowCtx` with
907
+ * `runSteps: false` and a journaled result per step, which is what makes it
908
+ * affordable: `ingestRecording` runs ffmpeg, and this repo's test environment
909
+ * has none — the `ingestRecording` and `narrate` specs above assert the
910
+ * `FatalError` that produces, unconditionally.
911
+ *
912
+ * So a whole run of this desk is not reachable here and this file does not
913
+ * pretend otherwise; `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier
914
+ * with a built project. What IS reachable, and what nothing else asserts, is the
915
+ * ENGINE's half of the fatal/retryable contract: a step that throws a
916
+ * `FatalError` must fail its run on the first attempt rather than spending the
917
+ * six this body asks for. The step specs above assert the class; only a real
918
+ * engine can assert what the class is FOR.
919
+ */
920
+ describe("the run is DURABLE, as far as ffmpeg allows", () => {
921
+ beforeEach(() => {
922
+ installStubUploads({
923
+ [UPLOAD_ID]: { bytes: new Uint8Array(2048), name: "call.m4a", type: "audio/mp4" },
924
+ });
925
+ installStubReporter();
926
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key");
927
+ });
928
+
929
+ test("a FatalError in the first step fails the run on ONE attempt, not six", async () => {
930
+ const run = await runWorkflow(audit, { recording: UPLOAD_ID }, { name: "audit" });
931
+
932
+ expect(run.status).toBe("failed");
933
+ expect(run.error).toMatch(/ffmpeg/i);
934
+ const ingest = run.steps.find((step) => step.name === "ingestRecording");
935
+ // The whole point of `FatalError`: `maxAttempts: 6` is the budget this call
936
+ // site asks for, and a failure that cannot change must not spend it. A step
937
+ // that burned all six here would delay the real error by minutes in
938
+ // production and cost five more conversions of a file that is not going to
939
+ // convert.
940
+ expect(ingest?.status).toBe("failed");
941
+ expect(ingest?.attempts).toBe(1);
942
+ });
943
+
944
+ test("the failure is DURABLE: a redelivery reports it rather than trying again", async () => {
945
+ const run = await runWorkflow(audit, { recording: UPLOAD_ID }, { name: "audit" });
946
+ expect(run.status).toBe("failed");
947
+
948
+ // A terminal run is done, and a redelivery of one is ordinary rather than an
949
+ // error — the platform's queue acks on a 200, so any delivery whose ack was
950
+ // lost arrives again after the run finished.
951
+ await run.restart();
952
+ expect(run.status).toBe("failed");
953
+ expect(run.steps.filter((step) => step.name === "ingestRecording")).toHaveLength(1);
954
+ expect(run.deliveries).toBe(2);
955
+ });
956
+
957
+ test("the clock is journaled BEFORE the step that fails, so the run's start survives", async () => {
958
+ // `ctx.now()` and the ingest are issued together in one `Promise.all`, so a
959
+ // failing ingest must not lose the clock read that went out beside it. It is
960
+ // journaled through the same append a step is, into a key space of its own —
961
+ // which is why it reads back off `reads` rather than `steps`.
962
+ const run = await runWorkflow(audit, { recording: UPLOAD_ID }, { name: "audit" });
963
+
964
+ const clock = run.reads.find((read) => read.key === "now!0");
965
+ expect(clock?.kind).toBe("now");
966
+ expect(typeof clock?.value).toBe("number");
888
967
  });
889
968
  });