@alexkroman1/aai-cli 9.2.0 → 10.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 (81) hide show
  1. package/dist/{_bundler-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
  2. package/dist/_bundler.d.ts +3 -41
  3. package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
  4. package/dist/_dev-server.d.ts +1 -8
  5. package/dist/_dev-vite-config.d.ts +21 -0
  6. package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
  7. package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
  8. package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
  9. package/dist/_templates.d.ts +29 -0
  10. package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
  11. package/dist/cli.mjs +46 -22
  12. package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
  13. package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
  14. package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
  15. package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
  16. package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
  17. package/dist/scaffold/CLAUDE.md +162 -54
  18. package/dist/scaffold/package.json +4 -6
  19. package/dist/scaffold/server.mjs +12 -23
  20. package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
  21. package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
  22. package/dist/templates/call-audit/agent.eval.test.ts +1 -1
  23. package/dist/templates/call-audit/agent.test.ts +80 -8
  24. package/dist/templates/call-audit/client.tsx +87 -3
  25. package/dist/templates/call-audit/workflows/audit.ts +52 -32
  26. package/dist/templates/call-audit/workflows/ingest.ts +15 -15
  27. package/dist/templates/call-audit/workflows/media.ts +1 -1
  28. package/dist/templates/call-audit/workflows/summarize.ts +0 -4
  29. package/dist/templates/link-digest/agent.eval.test.ts +4 -2
  30. package/dist/templates/link-digest/agent.test.ts +25 -5
  31. package/dist/templates/link-digest/client.tsx +65 -7
  32. package/dist/templates/link-digest/workflows/digest.ts +39 -29
  33. package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
  34. package/dist/templates/podcast-digest/agent.test.ts +108 -2
  35. package/dist/templates/podcast-digest/client.tsx +89 -8
  36. package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
  37. package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
  38. package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
  39. package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
  40. package/dist/templates/recap-workflow/agent.test.ts +323 -83
  41. package/dist/templates/recap-workflow/shared.ts +4 -3
  42. package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
  43. package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
  44. package/dist/templates/redline/agent.eval.test.ts +1 -1
  45. package/dist/templates/redline/agent.test.ts +1 -1
  46. package/dist/templates/redline/client.tsx +83 -3
  47. package/dist/templates/redline/workflows/redline.ts +26 -23
  48. package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
  49. package/dist/templates/research-workflow/agent.test.ts +32 -6
  50. package/dist/templates/research-workflow/agent.ts +4 -4
  51. package/dist/templates/research-workflow/shared.ts +4 -3
  52. package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
  53. package/dist/templates/research-workflow/workflows/research.ts +35 -38
  54. package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
  55. package/dist/templates/spoken-summary/agent.test.ts +10 -8
  56. package/dist/templates/spoken-summary/client.tsx +72 -3
  57. package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
  58. package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
  59. package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
  60. package/dist/templates/transcription-workflow/agent.test.ts +626 -14
  61. package/dist/templates/transcription-workflow/agent.ts +6 -4
  62. package/dist/templates/transcription-workflow/client.tsx +106 -308
  63. package/dist/templates/transcription-workflow/recover.test.ts +141 -0
  64. package/dist/templates/transcription-workflow/recover.ts +130 -0
  65. package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
  66. package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
  67. package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
  68. package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
  69. package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
  70. package/dist/templates/transcription-workflow/workflows/stream.ts +177 -24
  71. package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
  72. package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
  73. package/dist/worker-bundler.d.ts +0 -14
  74. package/dist/worker-bundler.mjs +149 -1
  75. package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
  76. package/dist/workflow.d.ts +1 -0
  77. package/package.json +4 -6
  78. package/dist/_workflow-scan.d.ts +0 -108
  79. package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
  80. package/dist/workflow-bundler.d.ts +0 -138
  81. package/dist/{_studio-CKrsixd0.mjs → _studio-B1waXMuR.mjs} +1 -1
@@ -13,19 +13,25 @@
13
13
  * The workflow BODY is not tested here: it is only durable once the Workflow
14
14
  * DevKit's build has transformed it, so a unit test of it would exercise a plain
15
15
  * async function and prove nothing about replay. Its STEPS are, and directly —
16
- * imported with no bundler in the path a `"use step"` function is an ordinary
16
+ * a step is an ordinary exported async function, so
17
17
  * async function, so its HTML handling, its JSON contract with the model and its
18
18
  * `FatalError` guards are all testable.
19
19
  */
20
20
 
21
- import { schemaInputIssues } from "@alexkroman1/aai/testing";
21
+ import { createWorkflowCtx, schemaInputIssues } from "@alexkroman1/aai/testing";
22
22
  import {
23
23
  installStubStepFetch,
24
24
  installStubGateway as stubGateway,
25
25
  } from "@alexkroman1/aai/testing/vitest";
26
26
  import { beforeEach, describe, expect, test, vi } from "vitest";
27
27
  import agentDef, { digest } from "./agent.ts";
28
- import { extractText, extractTitle, fetchArticle, summarize } from "./workflows/digest.ts";
28
+ import {
29
+ digestFlow,
30
+ extractText,
31
+ extractTitle,
32
+ fetchArticle,
33
+ summarize,
34
+ } from "./workflows/digest.ts";
29
35
 
30
36
  describe("the agent declares itself a workflow app", () => {
31
37
  test("under the name the page starts a run by", () => {
@@ -208,7 +214,21 @@ describe("summarize", () => {
208
214
  await expect(summarize(ARTICLE)).rejects.toThrow(/ASSEMBLYAI_API_KEY/);
209
215
  });
210
216
 
211
- test("retries beyond the default, because a rate limit and a bad format both happen", () => {
212
- expect(summarize.maxRetries).toBeGreaterThan(3);
217
+ test("is called with more attempts than the default, because a rate limit and a bad format both happen", async () => {
218
+ // The retry policy is an argument to `ctx.step` now, not a `maxRetries`
219
+ // property on the function — so the assertion is about the BODY's call,
220
+ // which is the only place the policy is observable at all. `runSteps: false`
221
+ // because the subject is the declared policy rather than the work: the steps
222
+ // would otherwise need a page and a model.
223
+ const ctx = createWorkflowCtx({ runSteps: false });
224
+ await digestFlow({ url: "https://example.com/a" }, ctx);
225
+
226
+ const summarizeStep = ctx.steps.find((step) => step.name === "summarize");
227
+ expect(summarizeStep?.maxAttempts).toBeGreaterThan(3);
228
+ // The order is the body's, and it is worth pinning beside the policy: the
229
+ // fetch is separate from the model call precisely so a rate-limited
230
+ // summarize replays the fetch from the journal instead of hitting a
231
+ // stranger's server again.
232
+ expect(ctx.steps.map((step) => step.name)).toEqual(["fetchArticle", "summarize", "file"]);
213
233
  });
214
234
  });
@@ -9,8 +9,30 @@
9
9
  *
10
10
  * What replaces `useSession()` is `useWorkflowSubmit()`: it starts the run,
11
11
  * follows its STATUS, and hands back the controls bound to it — `wake`, `cancel`
12
- * and `reset`. The API is durable, so the `runId` is the whole state — it
13
- * survives a reload, a different device, or `curl`.
12
+ * and `reset`.
13
+ *
14
+ * ## The run survives a reload; the run ID does not
15
+ *
16
+ * A `runId` names a run for as long as anything is holding it, and this page
17
+ * holds it in React state — so a refresh loses it while the run carries on
18
+ * without it. That is the wrong half of durability to leave to the reader: the
19
+ * page used to promise "the run continues without it" and then had no way back
20
+ * to the run it was promising about.
21
+ *
22
+ * `key` is the handle that survives, and `recover` is what reads it back. Two
23
+ * lines of wiring, one decision:
24
+ *
25
+ * - **The key is OPAQUE and lives in `sessionStorage`.** It names nothing about
26
+ * the person or the link — `useRunKey()` is the SDK's, and its module argues
27
+ * what that rules out (a key derived from the URL being digested, a `?key=`
28
+ * parameter) and why. Six templates had written those twenty lines each.
29
+ * - **`recover: true`** makes the hook ask `find("digest", key)` as it mounts
30
+ * and follow whatever run comes back, so a reload lands back on the same
31
+ * headline, the same progress log and the same buttons.
32
+ *
33
+ * Deployed, this needs the correlation-key index, which is a `DATABASE_URL`
34
+ * away — `agent.ts` says what happens without one (the runs are still durable;
35
+ * the index that finds them by key is in memory).
14
36
  *
15
37
  * ## The FORM here is still written by hand, deliberately
16
38
  *
@@ -31,13 +53,16 @@
31
53
  * cheap: one stream each, ended by the agent when there is nothing left to say.
32
54
  *
33
55
  * Progress also REPLAYS — chunks are retained with the run — so a reload mid-run
34
- * catches up rather than starting from whatever arrives next. `lines={1}` is
56
+ * catches up rather than starting from whatever arrives next. That only pays off
57
+ * because the reload can name its run again: `<WorkflowProgress runId>` is handed
58
+ * `run?.runId`, so before `recover` a refresh replayed a log for nobody.
59
+ * `lines={1}` is
35
60
  * what narrows it to the newest line, because on a page this small that is the
36
61
  * whole of what a status wants; `transcription-workflow` renders the full log,
37
62
  * where a fan-out makes the history worth seeing.
38
63
  */
39
64
 
40
- import { page, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
65
+ import { page, useRunKey, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
41
66
  import "@alexkroman1/aai-ui/styles.css";
42
67
  // ERASED at build time, so naming the agent's own type costs the browser bundle
43
68
  // nothing — and it is what stops this file restating a shape `workflows/
@@ -45,20 +70,52 @@ import "@alexkroman1/aai-ui/styles.css";
45
70
  import { useState } from "react";
46
71
  import type { digest } from "./agent.ts";
47
72
 
73
+ /**
74
+ * What the page says while something is in flight — three situations, one line
75
+ * each, and none of them the sentence this page used to print.
76
+ *
77
+ * That one was "You can close this tab — the run continues without it": true
78
+ * about the run and false about the page, which is the worst shape a reassurance
79
+ * can have. The run did continue and the tab could never find it again. Now it
80
+ * can, so the promise gets stronger and the reload case gets its own words —
81
+ * somebody who did not press the button is owed an explanation for the work
82
+ * appearing in front of them.
83
+ */
84
+ function pendingNote(startedHere: boolean, found: boolean): string {
85
+ if (startedHere)
86
+ return "You can close this tab or reload it — this page will find the run again.";
87
+ if (!found) return "Looking for a digest this tab started earlier…";
88
+ return "Still working on the digest this tab started earlier. Reloading is safe.";
89
+ }
90
+
48
91
  export function App() {
49
92
  const [url, setUrl] = useState("");
93
+ // This tab's handle on its own runs — minted once and remembered, which is
94
+ // what a later load produces to find the run again.
95
+ const key = useRunKey();
96
+ // Did THIS load start the run? A reload cannot have, and that is the only way
97
+ // the page can tell "working on what you just submitted" from "picking up
98
+ // where you left off" — the hook reports the run, not who asked for it.
99
+ const [startedHere, setStartedHere] = useState(false);
50
100
  // The generic is what makes `run.status === "completed"` narrow to a TYPED
51
101
  // `run.output` instead of `unknown`. `error` is the agent's own sentence for a
52
102
  // rejected input, which is better copy than anything this page could write, and
53
103
  // `wake` is bound to whatever run the hook is following — the whole reason this
54
104
  // page no longer holds a `createWorkflowApi()` of its own.
55
- const { submit, run, pending, error, wake } = useWorkflowSubmit<typeof digest>("digest");
105
+ const { submit, run, pending, error, wake } = useWorkflowSubmit<typeof digest>("digest", {
106
+ // Recorded with the run, and read back on the next load. Neither half is
107
+ // useful alone: without the key there is nothing to find the run by, and
108
+ // without `recover` the key is only ever written.
109
+ key,
110
+ recover: true,
111
+ });
56
112
 
57
113
  // `submit()` resolves as soon as the run exists — deliberately not when it
58
114
  // finishes. That is the whole mechanism: the digest sleeps for a while, and
59
115
  // this page is free to be closed in the meantime.
60
116
  const onSubmit = (event: React.FormEvent) => {
61
117
  event.preventDefault();
118
+ setStartedHere(true);
62
119
  void submit({ url });
63
120
  };
64
121
 
@@ -90,8 +147,9 @@ export function App() {
90
147
 
91
148
  {/* A run that has not settled says so. `pending` is not derivable from the
92
149
  snapshot alone — an id the agent never knew leaves `run` undefined,
93
- which would otherwise read as "still waiting" forever. */}
94
- {pending && <p>You can close this tab the run continues without it.</p>}
150
+ which would otherwise read as "still waiting" forever, and on a reload
151
+ it is also true while the run is being looked up by key. */}
152
+ {pending && <p>{pendingNote(startedHere, run !== undefined)}</p>}
95
153
 
96
154
  {/* The run's own narration, newest line only. `lines={1}` is the window;
97
155
  everything else — the replay, and the "serves no stream" case that is
@@ -1,7 +1,7 @@
1
1
  // Copyright 2026 the AAI authors. MIT license.
2
2
  /**
3
- * The durable half of the link digest: a `"use workflow"` body and its steps,
4
- * and the steps really read the page and really call a model.
3
+ * The durable half of the link digest: a workflow body and its steps, and the
4
+ * steps really read the page and really call a model.
5
5
  *
6
6
  * The rules are the same ones `research-workflow/workflows/research.ts` spells out
7
7
  * — the body is replayed from the top on every resume, so it holds no live
@@ -24,14 +24,19 @@
24
24
  * fetched text crosses a queue between them, which is what the cap on it is for.
25
25
  */
26
26
 
27
+ import type { WorkflowCtx } from "@alexkroman1/aai";
27
28
  import { report } from "@alexkroman1/aai/step";
28
- import { stepFetchOk, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
29
+ import { FatalError, stepFetchOk, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
29
30
  import { decodeHtmlEntities } from "@alexkroman1/aai/utils";
30
- import { FatalError, sleep } from "workflow";
31
31
  import { z } from "zod";
32
32
 
33
- /** How long the digest sits before it is filed, so the wait is visible in dev. */
34
- const SETTLE = "10 seconds";
33
+ /**
34
+ * How long the digest sits before it is filed, so the wait is visible in dev.
35
+ *
36
+ * Milliseconds. `ctx.sleep` takes a number or a `Date` and no duration STRING —
37
+ * one more parser to own, and no call site in the repo passed one.
38
+ */
39
+ export const SETTLE_MS = 10_000;
35
40
 
36
41
  /**
37
42
  * Characters of article text carried between the two steps.
@@ -86,31 +91,36 @@ export type Article = {
86
91
  * literally the page's render model, and `WorkflowOutputOf<typeof digest>` in
87
92
  * `client.tsx` is that type, derived rather than restated.
88
93
  */
89
- export async function digestFlow(input: { url: string }) {
90
- "use workflow";
91
-
92
- const article = await fetchArticle(input.url);
93
- const digest = await summarize(article);
94
+ export async function digestFlow(input: { url: string }, ctx: WorkflowCtx) {
95
+ const article = await ctx.step("fetchArticle", () => fetchArticle(input.url));
96
+ // `maxAttempts: 6` was `summarize.maxRetries = 5` — five retries AFTER the
97
+ // first attempt, so six in all. The retry policy moved from a property on the
98
+ // function to the CALL, which is where it belongs: the same function called
99
+ // from two places may deserve different patience, and a property could not say
100
+ // so. A rate limit and a model that ignored the format are both expected here.
101
+ const digest = await ctx.step("summarize", () => summarize(article), { maxAttempts: 6 });
94
102
 
95
103
  // Suspended, not blocked: the sandbox is free to exit here and the run
96
- // resumes when it comes due. Nothing about the code changes if it is
97
- // `"6 hours"` — which is the interesting version, and the one that makes an
104
+ // resumes when it comes due. Nothing about the code changes if it is six
105
+ // hours — which is the interesting version, and the one that makes an
98
106
  // overnight digest a digest rather than a slow request.
99
- await sleep(SETTLE);
107
+ await ctx.sleep(SETTLE_MS);
100
108
 
101
- return { ...digest, filedAt: await file(digest) };
109
+ const filedAt = await ctx.step("file", () => file(digest));
110
+ return { ...digest, filedAt };
102
111
  }
103
112
 
104
113
  /**
105
114
  * Read the page.
106
115
  *
107
- * A step, so it runs once per successful execution and its result is journaled;
108
- * a replay returns that result instead of fetching again which matters here
109
- * more than usual, because the far side is somebody else's web server.
116
+ * An ORDINARY exported async function what makes it a step is the
117
+ * `ctx.step("fetchArticle", …)` that calls it, so it runs once per successful
118
+ * execution and its result is journaled; a replay returns that result instead of
119
+ * fetching again, which matters here more than usual because the far side is
120
+ * somebody else's web server. Being ordinary is also what lets `agent.test.ts`
121
+ * call it directly with no engine in the path.
110
122
  */
111
123
  export async function fetchArticle(url: string): Promise<Article> {
112
- "use step";
113
-
114
124
  const { hostname } = new URL(url);
115
125
  await report(`Reading ${hostname}…`);
116
126
 
@@ -124,7 +134,7 @@ export async function fetchArticle(url: string): Promise<Article> {
124
134
  // `stepFetchOk` rather than `stepFetch` + an `ok` check: it makes the
125
135
  // retryable/terminal split for us — a 404 or a 403 answers the same way on
126
136
  // the fourth attempt, while a rate limit is exactly what retries are for, and
127
- // its `Retry-After` reaches the DevKit's schedule instead of the default
137
+ // its `Retry-After` reaches the engine's schedule instead of the default
128
138
  // backoff. It also puts the server's own error text in the message.
129
139
  const response = await stepFetchOk(url, {
130
140
  // Some sites answer a bare request with a challenge page; asking for HTML
@@ -156,8 +166,6 @@ export async function fetchArticle(url: string): Promise<Article> {
156
166
  * whole Node runtime is available here, unlike in the body.
157
167
  */
158
168
  export async function summarize(article: Article): Promise<Digest> {
159
- "use step";
160
-
161
169
  await report("Pulling out the claims worth keeping.");
162
170
 
163
171
  // `stepGenerateJsonClassified` unwraps the fence a model puts around JSON,
@@ -183,9 +191,6 @@ export async function summarize(article: Article): Promise<Digest> {
183
191
  };
184
192
  }
185
193
 
186
- /** A rate limit — and a model that ignored the format — are both expected here. */
187
- summarize.maxRetries = 5;
188
-
189
194
  /**
190
195
  * File the digest.
191
196
  *
@@ -193,11 +198,16 @@ summarize.maxRetries = 5;
193
198
  * replays the expensive half for free and re-issues only the cheap one.
194
199
  * Returning the timestamp rather than reading a clock in the BODY is the same
195
200
  * rule — a step's result is journaled and therefore stable across replays,
196
- * where `Date.now()` in the body would change on every one.
201
+ * where the same read in the body would change on every one.
202
+ *
203
+ * The `new Date()` below is therefore a BASELINED occurrence of
204
+ * `guard-invariants` rule 30, and this is the reason: it is inside a step, not
205
+ * inside a body. The rule bans a clock read anywhere in a shipped `workflows/`
206
+ * file because the `ctx.step` callback boundary is not decidable from a line;
207
+ * `digestFlow` is what reaches this one, as `ctx.step("file", () =>
208
+ * file(digest))`. Anything at BODY level is the bug, not an exception.
197
209
  */
198
210
  export async function file(_digest: Digest): Promise<string> {
199
- "use step";
200
-
201
211
  await report("Filing the digest.");
202
212
  // A real desk would write the digest to its database here. The stub writes
203
213
  // nothing, which is what the `_` says — and it is a stub because `ctx.db` is
@@ -32,7 +32,7 @@
32
32
  //
33
33
  // WHAT NO EVAL HERE COVERS: durability — which for THIS template is most of
34
34
  // what it is for. Imported through vitest with no bundler in the path, a
35
- // `"use workflow"` body is an ordinary async function, so the multi-day
35
+ // workflow body is an ordinary async function, so the multi-day
36
36
  // suspension that makes a digest arrive tomorrow is not exercised; the sleep is
37
37
  // RECORDED and skipped. `run.slept` below is that admission written as an
38
38
  // assertion, and it is the only way to check a seven-day schedule without
@@ -43,7 +43,7 @@ import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
43
43
  import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
44
44
  import { expect } from "vitest";
45
45
  import agentDef, { dailyDigest } from "./agent.ts";
46
- import { MAX_POLL_ATTEMPTS, POLL_DELAY, scheduleIntervalMs } from "./workflows/digest.ts";
46
+ import { MAX_POLL_ATTEMPTS, POLL_DELAY_MS, scheduleIntervalMs } from "./workflows/digest.ts";
47
47
 
48
48
  /** The feed every case reads. Not a real host — nothing here leaves the process. */
49
49
  const FEED_URL = "https://feeds.example.test/rebuild.xml";
@@ -320,9 +320,9 @@ describeWorkflowEval(agentDef, (test) => {
320
320
  expect(world.calls.filter((call) => call.url.includes("/v2/transcript/"))).toHaveLength(5);
321
321
  // Three waits for four rounds — asked for, and recorded rather than taken.
322
322
  expect(run.slept).toEqual([
323
- { duration: POLL_DELAY },
324
- { duration: POLL_DELAY },
325
- { duration: POLL_DELAY },
323
+ { duration: POLL_DELAY_MS },
324
+ { duration: POLL_DELAY_MS },
325
+ { duration: POLL_DELAY_MS },
326
326
  ]);
327
327
 
328
328
  // And the digest is in PUBLICATION order, not completion order. The feed is
@@ -10,7 +10,7 @@
10
10
  * - The DECLARATION — the config a deploy validates and the schema a `start()`
11
11
  * is checked against.
12
12
  * - The PURE helpers, pulled out of the flow for exactly this reason.
13
- * - The STEPS, directly. Imported with no bundler in the path a `"use step"`
13
+ * - The STEPS, directly. A step is an ordinary exported async function, so one
14
14
  * function is an ordinary async function, so its HTTP handling, its
15
15
  * partial-failure policy and its `FatalError` guards are all reachable —
16
16
  * `installStubStepFetch` answers the network and `stubGateway` answers the model.
@@ -21,7 +21,7 @@
21
21
  * that fails to transcribe taking the whole digest down with it.
22
22
  */
23
23
 
24
- import { parseSchemaInput, schemaInputIssues } from "@alexkroman1/aai/testing";
24
+ import { createWorkflowCtx, parseSchemaInput, schemaInputIssues } from "@alexkroman1/aai/testing";
25
25
  import {
26
26
  installStubStepFetch,
27
27
  installStubGateway as stubGateway,
@@ -29,7 +29,9 @@ import {
29
29
  import { beforeEach, describe, expect, test, vi } from "vitest";
30
30
  import agentDef, { dailyDigest } from "./agent.ts";
31
31
  import {
32
+ dailyDigestFlow,
32
33
  formatScheduleInterval,
34
+ POLL_DELAY_MS,
33
35
  pollTranscript,
34
36
  scheduleIntervalMs,
35
37
  submitTranscript,
@@ -714,3 +716,107 @@ describe("posting the digest", () => {
714
716
  expect((err as Error).name).not.toBe("FatalError");
715
717
  });
716
718
  });
719
+
720
+ describe("the body — the run that IS the schedule", () => {
721
+ /**
722
+ * The body driven end to end with no I/O.
723
+ *
724
+ * `runSteps: false` plus a skeleton of results: what this pins is the body's
725
+ * own logic — the digest loop, the shrinking pending set in
726
+ * `waitForTranscripts`, and the sleep between digests — none of which any
727
+ * per-step spec can see, and all of which is the template's actual subject.
728
+ */
729
+ function driveTwoDigests(pollResults: unknown) {
730
+ const ctx = createWorkflowCtx({
731
+ runSteps: false,
732
+ results: {
733
+ discoverEpisodes: [EPISODE],
734
+ submitTranscript: { id: EPISODE.id, transcriptId: "t_1" },
735
+ pollTranscript: pollResults,
736
+ summarizeTranscript: EPISODE,
737
+ postDigest: { ok: true },
738
+ timestamp: "2026-08-21T00:00:00.000Z",
739
+ },
740
+ });
741
+ return { ctx };
742
+ }
743
+
744
+ test("sends one digest per interval and sleeps BETWEEN them, never after the last", async () => {
745
+ // A run that has delivered everything it owes should end, not sleep for a
746
+ // day and then end.
747
+ const { ctx } = driveTwoDigests({
748
+ id: EPISODE.id,
749
+ transcriptStatus: "completed",
750
+ transcript: "words",
751
+ });
752
+
753
+ const output = await dailyDigestFlow(
754
+ {
755
+ ...VALID,
756
+ slackWorkflowTextParam: "text",
757
+ daysToRun: 2,
758
+ maxEpisodesPerDigest: 1,
759
+ intervalEvery: 2,
760
+ intervalUnit: "hours",
761
+ },
762
+ ctx,
763
+ );
764
+
765
+ expect(output.digestsSent).toBe(2);
766
+ expect(output.digestsScheduled).toBe(2);
767
+ // Two digests, ONE sleep.
768
+ expect(ctx.slept).toHaveLength(1);
769
+ expect(ctx.slept[0]?.until).toBe(scheduleIntervalMs(2, "hours"));
770
+ expect(ctx.steps.filter((step) => step.name === "postDigest")).toHaveLength(2);
771
+ });
772
+
773
+ test("reports the last digest it actually sent", async () => {
774
+ const { ctx } = driveTwoDigests({
775
+ id: EPISODE.id,
776
+ transcriptStatus: "completed",
777
+ transcript: "words",
778
+ });
779
+
780
+ const output = await dailyDigestFlow(
781
+ {
782
+ ...VALID,
783
+ slackWorkflowTextParam: "text",
784
+ daysToRun: 1,
785
+ maxEpisodesPerDigest: 1,
786
+ intervalEvery: 1,
787
+ intervalUnit: "days",
788
+ },
789
+ ctx,
790
+ );
791
+
792
+ expect(output.lastDigest).toMatchObject({
793
+ sentAt: "2026-08-21T00:00:00.000Z",
794
+ episodes: [EPISODE],
795
+ });
796
+ expect(ctx.slept).toEqual([]);
797
+ });
798
+
799
+ test("gives up on an episode that never finishes rather than failing the digest", async () => {
800
+ // Running out of poll rounds is NOT an error: a partial digest beats none,
801
+ // and the reason is printed where a reader will see it. The poll answers
802
+ // `submitted` forever, so the loop exhausts its rounds.
803
+ const { ctx } = driveTwoDigests({ id: EPISODE.id, transcriptStatus: "submitted" });
804
+
805
+ const output = await dailyDigestFlow(
806
+ {
807
+ ...VALID,
808
+ slackWorkflowTextParam: "text",
809
+ daysToRun: 1,
810
+ maxEpisodesPerDigest: 1,
811
+ intervalEvery: 1,
812
+ intervalUnit: "days",
813
+ },
814
+ ctx,
815
+ );
816
+
817
+ expect(output.digestsSent).toBe(1);
818
+ // Every round slept except the last, which is what bounds the wait.
819
+ expect(ctx.slept.length).toBeGreaterThan(0);
820
+ for (const sleep of ctx.slept) expect(sleep.until).toBe(POLL_DELAY_MS);
821
+ });
822
+ });
@@ -5,9 +5,46 @@
5
5
  *
6
6
  * Mounted with `page()` rather than `client()`: there is no session to build, so
7
7
  * no socket, no audio graph, no microphone request. `useWorkflowSubmit()` starts
8
- * the run, follows its STATUS, and hands back the controls bound to it. The API
9
- * is durable, so the `runId` is the whole state — it survives a reload, another
10
- * device, or `curl`.
8
+ * the run, follows its STATUS, and hands back the controls bound to it.
9
+ *
10
+ * ## The `runId` is durable; the PAGE holding it was not
11
+ *
12
+ * This doc used to say the run id "survives a reload, another device, or
13
+ * `curl`", and every word of that is true of the id and none of it was true of
14
+ * this page: the id lived in React state, so a refresh dropped it while a
15
+ * schedule that may run for a month carried on posting. That is the worst case
16
+ * in `templates/` for losing a handle — the other workflow apps lose a wait of
17
+ * minutes, this one loses the only Stop button a thirty-digest commitment has,
18
+ * and the run is invisible from then on to everything but `curl`.
19
+ *
20
+ * `key` is the handle that survives and `recover: true` is what reads it back,
21
+ * so a later load lands on the same count, the same newest line, and the same
22
+ * Wake and Cancel buttons bound to the same run.
23
+ *
24
+ * **The key is `useRunKey({ storage: "local" })`, and this is the one workflow
25
+ * app that passes that option.** Its siblings take the default,
26
+ * `sessionStorage`, which dies with the tab and covers exactly the interruption
27
+ * they have — a reload, a same-tab navigation, a crashed tab. A schedule
28
+ * outlives all of that by design: closing the browser on Tuesday and coming
29
+ * back on Friday to stop it is the ordinary case here, not an edge one, and a
30
+ * tab-scoped key would answer that with an empty form beside a run still
31
+ * posting to somebody's Slack. So the handle is scoped to the BROWSER, which is
32
+ * as far as it can go without a login (`find` has no per-user filtering; the
33
+ * key is the whole scoping mechanism) — and no further:
34
+ *
35
+ * - **Not the page's own URL.** A `?key=` parameter is pasted into chats,
36
+ * copied into referrers and kept in history, and what a leaked one buys here
37
+ * is not just reading the digest: it is `cancel()` on somebody's schedule,
38
+ * and a completed run's output NAMES the delivery target it has been posting
39
+ * to.
40
+ * - **Not derived from the feeds or the webhook.** Two people watching the same
41
+ * show would recover each other's schedules, and a key derived from a webhook
42
+ * URL would carry a credential into a lookup token — which is why the
43
+ * platform stopped writing keys to the operator's log.
44
+ *
45
+ * A real app with accounts passes the account's own id instead, and then the
46
+ * schedule follows the person to a new device — a promise only a login can
47
+ * keep.
11
48
  *
12
49
  * ## The form is DECLARED, not written
13
50
  *
@@ -51,6 +88,7 @@ import {
51
88
  Form,
52
89
  page,
53
90
  SubmitButton,
91
+ useRunKey,
54
92
  useWorkflowSubmit,
55
93
  WorkflowFields,
56
94
  WorkflowProgress,
@@ -59,18 +97,54 @@ import "@alexkroman1/aai-ui/styles.css";
59
97
  // ERASED at build time, so naming the agent's own type costs the browser bundle
60
98
  // nothing — and it is what stops this file restating a shape `workflows/
61
99
  // digest.ts` already declares.
100
+ import { useState } from "react";
62
101
  import type { dailyDigest } from "./agent.ts";
63
102
 
64
103
  /** The workflow this page drives. Matches the key in `workflowApp({ workflows })`. */
65
104
  const WORKFLOW = "dailyDigest";
66
105
 
106
+ /**
107
+ * What the page says while a schedule is live — three situations, one line
108
+ * each, and none of them the sentence this page used to print.
109
+ *
110
+ * That one was "You can close this tab — the run continues without it": true
111
+ * about the run and false about the page, which is the worst shape a
112
+ * reassurance can have. The run did continue, for up to a month, and nothing
113
+ * could name it again. Now the promise can be stronger AND narrower — this
114
+ * browser, not any tab anywhere — and the load that did not press the button
115
+ * gets its own words, because a schedule appearing in front of somebody is owed
116
+ * an explanation.
117
+ */
118
+ function pendingNote(startedHere: boolean, found: boolean): string {
119
+ if (startedHere)
120
+ return "You can close this tab — the digest keeps posting, and this browser will find it again.";
121
+ if (!found) return "Looking for a schedule this browser started earlier…";
122
+ return "This is a schedule this browser started earlier. It is still posting.";
123
+ }
124
+
67
125
  export function App() {
126
+ // This BROWSER's handle on its schedules — minted once and kept for as long
127
+ // as storage lives for this origin, which is the option this template exists
128
+ // to argue for. See the module doc.
129
+ const key = useRunKey({ storage: "local" });
130
+ // Did THIS load start the schedule? A later load cannot have, and that is the
131
+ // only way the page can tell "scheduled just now" from "still running from
132
+ // Tuesday" — the hook reports the run, not who asked for it.
133
+ const [startedHere, setStartedHere] = useState(false);
68
134
  // The generic is what makes `run.status === "completed"` narrow to a TYPED
69
135
  // `run.output` instead of `unknown`. `error` is the agent's own sentence for a
70
136
  // rejected input — better copy than anything this page could write, and the
71
137
  // reason there is no `try`/`catch` here.
72
- const { submitForm, run, pending, error, wake, cancel } =
73
- useWorkflowSubmit<typeof dailyDigest>(WORKFLOW);
138
+ const { submitForm, run, pending, error, wake, cancel } = useWorkflowSubmit<typeof dailyDigest>(
139
+ WORKFLOW,
140
+ {
141
+ // Recorded with the run, and read back on the next load. Neither half is
142
+ // useful alone: without the key there is nothing to find the run by, and
143
+ // without `recover` the key is only ever written.
144
+ key,
145
+ recover: true,
146
+ },
147
+ );
74
148
 
75
149
  return (
76
150
  <main className="mx-auto flex max-w-2xl flex-col gap-6 p-8">
@@ -78,7 +152,13 @@ export function App() {
78
152
 
79
153
  {/* `submit()` resolves as soon as the run EXISTS — deliberately not when it
80
154
  finishes, which here could be a month away. */}
81
- <Form onSubmit={submitForm} error={error}>
155
+ <Form
156
+ onSubmit={(values) => {
157
+ setStartedHere(true);
158
+ return submitForm(values);
159
+ }}
160
+ error={error}
161
+ >
82
162
  <WorkflowFields workflow={WORKFLOW} />
83
163
  <SubmitButton pending={pending}>
84
164
  {pending ? "Digest scheduled" : "Start digest"}
@@ -87,8 +167,9 @@ export function App() {
87
167
 
88
168
  {/* A run that has not settled says so. `pending` is not derivable from the
89
169
  snapshot alone — an id the agent never knew leaves `run` undefined,
90
- which would otherwise read as "still waiting" forever. */}
91
- {pending && <p>You can close this tab the run continues without it.</p>}
170
+ which would otherwise read as "still waiting" forever, and on a later
171
+ load it is also true while the schedule is being looked up by key. */}
172
+ {pending && <p>{pendingNote(startedHere, run !== undefined)}</p>}
92
173
 
93
174
  {/* The run's own narration, newest line only. `lines={1}` is the window;
94
175
  everything else — the replay, and the "serves no stream" case that is