@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,13 +13,57 @@
13
13
  * | Temporal sample | Ported here as |
14
14
  * | --- | --- |
15
15
  * | `saga` — `openAccount`'s compensation stack | {@link recapFlow}'s `compensations`, unwound by {@link compensate} |
16
- * | `polling` — infrequent polling | {@link awaitTranscript}: a bounded loop of one step plus one durable `sleep` |
17
- * | `timer-examples` — `processOrderWorkflow` | the `Promise.race` against {@link PATIENCE}, then the "still going" note |
18
- * | `expense` — `timeoutOrUserAction` | the RETENTION GATE: a hook raced against {@link RETENTION_WINDOW}, three outcomes and a safe default |
16
+ * | `polling` — infrequent polling | {@link awaitTranscript}'s loop: one step plus one durable `sleep`, and the BACKSTOP under the callback below |
17
+ * | `timer-examples` — `processOrderWorkflow` | the "still going" note, at {@link PATIENCE_POLLS} turns of the loop or one closed {@link CALLBACK_WINDOW_MS} |
18
+ * | `expense` — `timeoutOrUserAction` | the RETENTION GATE: one `ctx.waitFor` with {@link RETENTION_WINDOW_MS}, three outcomes and a safe default |
19
19
  *
20
20
  * The voice half — start, query, cancel, and the answer to the gate — is ported
21
21
  * in `agent.ts`.
22
22
  *
23
+ * ## The provider CALLS BACK, and the poll is what makes that safe
24
+ *
25
+ * AssemblyAI's async API takes a `webhook_url` on submission, so the ordinary
26
+ * case does not need a poll at all: {@link callbackUrl} mints one with
27
+ * `stepWebhookUrl`, {@link submitRecording} hands it over, and the body parks on
28
+ * `ctx.waitFor` until the delivery lands. The run is SUSPENDED throughout — the
29
+ * same as a `sleep`, so this is not a saving on resident process time; what it
30
+ * buys is one status read instead of nine, and a recap that starts being written
31
+ * the second the transcript exists rather than up to fifteen seconds later.
32
+ *
33
+ * **`stepWebhookUrl` is the step-side half of
34
+ * `ctx.workflows.publicWebhookUrl`,** and until it existed this conversion was
35
+ * not available to a workflow at all: the tool-side accessor needs a
36
+ * `ToolContext`, and a body and its steps are handed none. This template could
37
+ * have reached it through its own tools — it has five — but the URL would then
38
+ * have had to travel as run input, which is a shape the three `workflowApp()`
39
+ * templates (no tools at all) could not copy. The step helper is the one both
40
+ * can use.
41
+ *
42
+ * **The poll did not go away, and it must not.** A webhook is one HTTP POST from
43
+ * a third party with no delivery guarantee anyone here controls: AssemblyAI
44
+ * retries ten times at ten-second intervals and then gives up permanently, a
45
+ * deployment may not know its own public URL at all, and a delivery that arrives
46
+ * in the milliseconds before the body reaches its wait is answered `404` and
47
+ * dropped. So the callback is an OPTIMIZATION OVER A RECONCILING READ, never a
48
+ * replacement for one: {@link awaitTranscript} reads the status before it parks
49
+ * and again after, and if nothing ever arrives it degrades to exactly the loop
50
+ * it always was. A template that hung forever on a dropped delivery would be
51
+ * strictly worse than one that polls. That rule generalizes to every event
52
+ * source in this product — the event tells you WHEN to look, and the read is
53
+ * what tells you what happened.
54
+ *
55
+ * **The delivery cannot be the answer even in principle, and that is what makes
56
+ * an unauthenticated callback safe here.** AssemblyAI's payload is
57
+ * `{transcript_id, status}` and nothing else — no text, no error detail — so the
58
+ * run has to `GET` the transcript regardless of who knocked. This body therefore
59
+ * does not read the payload at all: a forged delivery on a guessed token costs
60
+ * exactly one extra status read and changes no decision, because every decision
61
+ * is made from what the provider's own endpoint says under this desk's own
62
+ * credential. That is a better guarantee than a shared secret would be — it
63
+ * holds by construction rather than by a credential somebody has to rotate. See
64
+ * {@link awaitTranscript} for the auth header the provider offers and why this
65
+ * template does not set one.
66
+ *
23
67
  * ## The gate is the one that needed a new SDK primitive
24
68
  *
25
69
  * Temporal's `expense` sample parks a workflow on a signal until a human
@@ -67,17 +111,18 @@
67
111
  * just your shell.
68
112
  */
69
113
 
70
- import { report, requireStepEnv, stepFetch } from "@alexkroman1/aai/step";
114
+ import { isWorkflowSuspend, type WorkflowCtx } from "@alexkroman1/aai";
115
+ import { report, requireStepEnv, stepFetch, stepWebhookUrl } from "@alexkroman1/aai/step";
71
116
  import {
117
+ FatalError,
72
118
  stepFetchOk,
73
119
  stepGenerateJsonClassified,
74
120
  stepTranscribeSubmitClassified,
75
121
  toStepError,
76
122
  } from "@alexkroman1/aai/step-errors";
77
123
  import { errorMessage, isRecord, omitUndefined } from "@alexkroman1/aai/utils";
78
- import { createHook, FatalError, sleep } from "workflow";
79
124
  import { z } from "zod";
80
- import { retentionToken } from "./tokens.ts";
125
+ import { retentionToken, transcriptToken } from "./tokens.ts";
81
126
 
82
127
  /** AssemblyAI's pre-recorded (batch) transcription collection. */
83
128
  const TRANSCRIPT_ENDPOINT = "https://api.assemblyai.com/v2/transcript";
@@ -94,7 +139,7 @@ const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
94
139
  * file, and the docs' own 1–2 second advice is for a load test with a
95
140
  * rate-limit budget to spend.
96
141
  */
97
- const POLL_INTERVAL = "15 seconds";
142
+ const POLL_INTERVAL_MS = 15_000;
98
143
 
99
144
  /**
100
145
  * Polls before the desk gives up.
@@ -102,33 +147,79 @@ const POLL_INTERVAL = "15 seconds";
102
147
  * A bound rather than a deadline, because it is what the LOOP can enforce with
103
148
  * nothing but journaled values: attempt N is attempt N on every replay, where a
104
149
  * wall-clock deadline read in the body would move under it. At
105
- * {@link POLL_INTERVAL} this is twenty minutes, which is far past the
150
+ * {@link POLL_INTERVAL_MS} this is twenty minutes, which is far past the
106
151
  * turnaround of any recording a phone caller will name.
107
152
  */
108
153
  const MAX_POLLS = 80;
109
154
 
110
155
  /**
111
- * How long the desk waits before admitting a recording is a long one.
156
+ * How many polls before the desk admits a recording is a long one.
157
+ *
158
+ * The port of Temporal's `processOrderWorkflow`, and the ONE place this port
159
+ * changes shape rather than vocabulary. There the pattern is a `Promise.race`
160
+ * between the work and a timer; here both a `ctx.sleep` and the work's own polls
161
+ * SUSPEND, and a suspend unwinds the stack — so racing them stops the body on
162
+ * whichever suspends first, before the other has been reached. Counting polls
163
+ * says the same thing with journaled values only: attempt N is attempt N on
164
+ * every replay, which is the property {@link MAX_POLLS} already rests on.
165
+ *
166
+ * NINE rather than eight, and the off-by-one is the whole subtlety of counting
167
+ * polls instead of watching a clock: the note goes out at the TOP of a poll, so
168
+ * what has elapsed by then is the sleeps BEHIND it — N-1 of them. At
169
+ * {@link POLL_INTERVAL_MS} that makes this two minutes, the wait the timer named;
170
+ * eight said the same sentence at 1:45, which is a desk calling a recording a
171
+ * long one a quarter of a minute before it is entitled to.
172
+ */
173
+ const PATIENCE_POLLS = 9;
174
+
175
+ /**
176
+ * How long one park on the provider's callback lasts.
177
+ *
178
+ * The same two minutes {@link PATIENCE_POLLS} counts out, and deliberately the
179
+ * same number: whichever arm the run is on, the caller hears "still transcribing"
180
+ * after two minutes of waiting and not before. What differs is only how many
181
+ * times the desk asked the provider to get there — nine reads, or one.
182
+ *
183
+ * **This is the first thing in the template that is a REAL timed race**, which
184
+ * is worth stopping on because {@link PATIENCE_POLLS}'s doc apologises at length
185
+ * for not being one. Temporal's `processOrderWorkflow` races the work against a
186
+ * timer; a poll count can only approximate that, and this file could not do
187
+ * better while both sides of the race were suspending calls. `waitFor(token,
188
+ * { timeoutMs })` IS the race — the delivery or the deadline, journaled as ONE
189
+ * decision — so the ported pattern finally has the shape it has upstream.
190
+ *
191
+ * Two minutes rather than the twenty the loop budgets, because the window is
192
+ * what a DROPPED delivery costs: nothing is lost when it closes, the run simply
193
+ * goes back to reading, so a short window buys most of the saving and bounds the
194
+ * worst case.
195
+ */
196
+ const CALLBACK_WINDOW_MS = 120_000;
197
+
198
+ /**
199
+ * Polls before the desk admits a recording is a long one, on the callback arm.
112
200
  *
113
- * The port of Temporal's `processOrderWorkflow`: race the work against a timer,
114
- * and if the timer wins, do the other thing there the delayed-order email,
115
- * here a progress line the caller hears when they ask. Then keep waiting for
116
- * the work either way.
201
+ * The same off-by-one {@link PATIENCE_POLLS} explains, one window instead of
202
+ * eight sleeps: the note goes out at the TOP of a poll, so what has elapsed by
203
+ * then is the waiting BEHIND it. Attempt 2 is the first turn with a whole closed
204
+ * {@link CALLBACK_WINDOW_MS} behind it, which is the two minutes.
117
205
  */
118
- const PATIENCE = "2 minutes";
206
+ const PATIENCE_POLLS_WITH_CALLBACK = 2;
119
207
 
120
208
  /**
121
209
  * How long the desk holds the transcript waiting for an answer.
122
210
  *
123
211
  * The port of `timeoutOrUserAction`: Temporal races a `condition()` against a
124
- * timeout, this races a hook against a `sleep`, and both have THREE outcomes —
125
- * approved, declined, nobody answered. The window is a `sleep`, so a caller who
126
- * hangs up costs nothing while it runs.
212
+ * timeout, this passes the window to `ctx.waitFor` as `timeoutMs`, and both have
213
+ * THREE outcomes — approved, declined, nobody answered. A parameter rather than
214
+ * a race for the reason {@link PATIENCE_POLLS} gives, and it is the better shape
215
+ * anyway: the deadline is journaled once, so a replay cannot extend the window,
216
+ * and the engine CLOSES the hook when it shuts so a late answer cannot be taken.
217
+ * The run is suspended throughout, so a caller who hangs up costs nothing.
127
218
  *
128
219
  * Two minutes because a caller is on the line; a desk whose approver is on email
129
- * would write `"2 days"` and nothing else in this file would change.
220
+ * would write two days and nothing else in this file would change.
130
221
  */
131
- const RETENTION_WINDOW = "2 minutes";
222
+ const RETENTION_WINDOW_MS = 120_000;
132
223
 
133
224
  /** Every HTTP call's deadline. `fetch` has none of its own, and a hung step never ends. */
134
225
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -204,9 +295,7 @@ export type Compensation = { label: string; undo: () => Promise<void> };
204
295
  * `agent.ts` reads back down the phone, so it is shaped for an ear rather than
205
296
  * a page.
206
297
  */
207
- export async function recapFlow(input: { url: string; requestedBy: string }) {
208
- "use workflow";
209
-
298
+ export async function recapFlow(input: { url: string; requestedBy: string }, ctx: WorkflowCtx) {
210
299
  // The compensation stack, newest first — `unshift` after each successful
211
300
  // acquisition, exactly as Temporal's `openAccount` does. Registering the undo
212
301
  // AFTER the step it undoes is the whole discipline: a step that never
@@ -215,34 +304,52 @@ export async function recapFlow(input: { url: string; requestedBy: string }) {
215
304
  const compensations: Compensation[] = [];
216
305
 
217
306
  try {
218
- const job = await submitRecording(input.url);
307
+ // The token this run's callback URL is minted for, and the one
308
+ // `awaitTranscript` parks on — derived from the run's own input in ONE place
309
+ // so the URL the provider is given and the string the body waits on cannot
310
+ // drift. They are separated by a third party on the public internet, which
311
+ // is as far apart as two halves of a contract get.
312
+ const nudge = transcriptToken(input.requestedBy);
313
+ // `callbackUrl` is evaluated INSIDE the step's function, so it runs once —
314
+ // on first execution — and never on a replay, which returns the journaled
315
+ // result without calling this at all. That is what makes the mint a
316
+ // journaled decision rather than one re-taken on every walk.
317
+ const job = await ctx.step("submitRecording", () =>
318
+ submitRecording(input.url, callbackUrl(nudge)),
319
+ );
219
320
  compensations.unshift({
220
321
  label: `transcript ${job.id}`,
221
- undo: () => discardTranscript(job.id),
322
+ undo: () => ctx.step("discardTranscript", () => discardTranscript(job.id)),
222
323
  });
223
324
 
224
- // Temporal's `processOrderWorkflow`, line for line: start the work, race it
225
- // against a timer, and if the timer wins first say so and then go on
226
- // waiting. `ready` is an ordinary local deterministic, because the only
227
- // thing that flips it is a journaled step result.
228
- let ready = false;
229
- const work = awaitTranscript(job.id).then((state) => {
230
- ready = true;
231
- return state;
232
- });
233
- await Promise.race([work, sleep(PATIENCE)]);
234
- if (!ready) await note("Still transcribing — this is a long one. I'll keep going.");
235
- const transcript = await work;
325
+ // `job.callback` rather than a fresh mint: the branch has to come out of the
326
+ // JOURNAL, or a redeploy mid-run could flip it see {@link submitRecording}.
327
+ const transcript = await awaitTranscript(job.id, ctx, job.callback ? nudge : undefined);
236
328
 
237
- const recap = await summarize(input.url, transcript);
238
- const retention = await askWhetherToKeep(input.requestedBy, job.id, compensations);
329
+ const recap = await ctx.step("summarize", () => summarize(input.url, transcript), {
330
+ // Was `summarize.maxRetries = 5` five retries after the first attempt.
331
+ maxAttempts: 6,
332
+ });
333
+ const retention = await askWhetherToKeep(input.requestedBy, job.id, compensations, ctx);
239
334
  return { ...recap, ...retention, requestedBy: input.requestedBy };
240
335
  } catch (err) {
336
+ // **A suspend is not a failure, and this catch is why that matters.** The
337
+ // body above WAITS three ways now — `awaitTranscript` parks on the
338
+ // provider's callback, then sleeps between polls, and the gate waits for an
339
+ // answer — and every one of them suspends by throwing, so it lands here.
340
+ // Without this line the first poll that had to wait unwound the compensation
341
+ // stack, DELETED the transcript the run was waiting for, journaled the
342
+ // deletion as successful and re-threw; the engine saw its own signal come
343
+ // back out and recorded the run as healthily suspended. The data was gone and
344
+ // every signal said fine. `replayRun` now also fails a run that swallows one,
345
+ // so a body that forgets this is loud rather than silently destructive — but
346
+ // the body is the place it belongs.
347
+ if (isWorkflowSuspend(err)) throw err;
241
348
  // The saga's whole point. Everything acquired above is released, in reverse,
242
349
  // before the failure is re-thrown — and because each undo is a STEP, a crash
243
350
  // during the unwind resumes with the finished ones replayed from the journal
244
351
  // rather than run twice.
245
- await compensate(compensations, errorMessage(err));
352
+ await compensate(compensations, errorMessage(err), ctx);
246
353
  throw err;
247
354
  }
248
355
  }
@@ -252,28 +359,90 @@ export async function recapFlow(input: { url: string; requestedBy: string }) {
252
359
  *
253
360
  * A body-side helper, not a step: it `sleep`s, and a step cannot — a step runs
254
361
  * to completion in a worker, where the body is what may suspend. Splitting it
255
- * out keeps `recapFlow` readable and costs nothing, since the WDK transform
256
- * rewrites a step's DECLARATION rather than its call sites, so a step called
257
- * from a helper is still a real step (`mapConcurrent` rests on the same
258
- * property).
362
+ * out keeps `recapFlow` readable and costs nothing: `ctx` is an ordinary value,
363
+ * so a helper handed one issues real steps. Note the occurrence counter is per
364
+ * RUN and not per function, so a name used here may not also be used in the body
365
+ * — the two call sites would alias onto one journal entry.
259
366
  *
260
367
  * The loop is deterministic despite looking like it is not: every branch turns
261
- * on a journaled step result, so a replay takes the same number of turns it took
262
- * live.
368
+ * on a journaled step result or on a journaled input field, so a replay takes
369
+ * the same number of turns it took live.
370
+ *
371
+ * ## Read first, then park ONCE, then read on a timer
372
+ *
373
+ * `nudge` is the callback token when `request_recap` managed to mint a URL, and
374
+ * `undefined` otherwise — in which case every line below behaves exactly as it
375
+ * did before there was a callback at all.
376
+ *
377
+ * The order matters and each part of it is paid for:
378
+ *
379
+ * - **The first read happens BEFORE the park**, so a job that finished while the
380
+ * submit response was in flight, or a delivery that arrived in the milliseconds
381
+ * before this body reached its wait and was dropped, costs nothing.
382
+ * - **The park happens ONCE, on the first turn only, and it HAS to.** A hook
383
+ * token may be claimed at most once per run: `claimHook` (both journal
384
+ * backends) throws `token … is already held by run …` for a second claim under
385
+ * a different occurrence key, and the token is given back only when the run
386
+ * goes TERMINAL — so a `ctx.waitFor` written inside this loop would fail the
387
+ * second time round. That is not a hypothetical. The comment block at
388
+ * `aai-runtime/workflow-journal-memory.ts:140-148` records this template
389
+ * getting it wrong once already: a `claimHook` conflict is a throw and a throw
390
+ * is not a suspend, so `recapFlow`'s `catch` treated it as a failed run, ran
391
+ * the compensation stack and DELETED the transcript. A template that teaches
392
+ * the wrong nesting here costs somebody their data.
393
+ * - **Every later turn is the plain cadence**, because a delivery that has not
394
+ * arrived within {@link CALLBACK_WINDOW_MS} is one to stop counting on. The
395
+ * loop from there is the `polling` port, unchanged, and it is what finishes the
396
+ * run whether the delivery was late, dropped, never sent, or forged.
397
+ *
398
+ * **The payload is not read, and that is the security argument.** `waitFor` is
399
+ * called for its EDGE — "something happened, go look" — and the answer comes
400
+ * from {@link checkTranscript} under this desk's own credential, so nothing a
401
+ * caller could POST to the public callback route changes an outcome. AssemblyAI
402
+ * does offer `webhook_auth_header_name`/`webhook_auth_header_value`, and this
403
+ * template sets neither: the receiving route
404
+ * (`/.well-known/workflow/v1/webhook/:token`) authorizes on the TOKEN and reads
405
+ * no other header, so a header set here would be sent and ignored — security
406
+ * theatre, and worse than none because it reads as a control.
263
407
  */
264
- export async function awaitTranscript(id: string): Promise<TranscriptState> {
408
+ export async function awaitTranscript(
409
+ id: string,
410
+ ctx: WorkflowCtx,
411
+ nudge?: string,
412
+ ): Promise<TranscriptState> {
413
+ // Which turn says "still going". A pure function of `nudge`, which the body
414
+ // derived from a journaled step result, so a replay picks the same turn.
415
+ const patienceAt = nudge === undefined ? PATIENCE_POLLS : PATIENCE_POLLS_WITH_CALLBACK;
416
+
265
417
  for (let attempt = 1; attempt <= MAX_POLLS; attempt++) {
266
- const state = await checkTranscript(id);
418
+ const state = await ctx.step("checkTranscript", () => checkTranscript(id));
267
419
  if (state.status === "completed") return state;
268
420
  if (state.status === "error") {
269
421
  // The provider's own terminal answer. A plain `Error`, not a
270
422
  // `FatalError`: this is the BODY, and a body's throw is never retried —
271
- // `FatalError` is the vocabulary for telling the DevKit not to retry a
423
+ // `FatalError` is the vocabulary for telling the ENGINE not to retry a
272
424
  // STEP, and using it here would claim a distinction that does not exist.
273
425
  throw new Error(`The provider could not transcribe that recording: ${state.error}`);
274
426
  }
275
- // Suspended, not blocked: nothing is resident while this waits.
276
- await sleep(POLL_INTERVAL);
427
+ // Once, at the point the timer used to fire — which is the top of the poll
428
+ // that follows two minutes of waiting, see {@link PATIENCE_POLLS} for the
429
+ // off-by-one. `attempt` is a journaled-value function, so a replay says it
430
+ // at the same turn or not at all.
431
+ if (attempt === patienceAt) {
432
+ await ctx.step("noteSlow", () =>
433
+ note("Still transcribing — this is a long one. I'll keep going."),
434
+ );
435
+ }
436
+ // Both arms SUSPEND — nothing is resident while either waits — and the only
437
+ // difference is what can end the wait early. The callback park is first-turn
438
+ // only; see this function's doc for why it cannot be every turn.
439
+ if (attempt === 1 && nudge !== undefined) {
440
+ // The payload is DISCARDED on purpose: this waits for the edge, and the
441
+ // read at the top of the next turn is what establishes the fact.
442
+ await ctx.waitFor(nudge, { timeoutMs: CALLBACK_WINDOW_MS });
443
+ } else {
444
+ await ctx.sleep(POLL_INTERVAL_MS);
445
+ }
277
446
  }
278
447
  throw new Error(`Gave up on that recording after ${MAX_POLLS} checks.`);
279
448
  }
@@ -282,8 +451,8 @@ export async function awaitTranscript(id: string): Promise<TranscriptState> {
282
451
  * Ask the caller whether the transcript stays on file, and act on the answer.
283
452
  *
284
453
  * The port of Temporal's `timeoutOrUserAction`, and a body-side helper for the
285
- * same reason {@link awaitTranscript} is: it opens a hook and `sleep`s, neither
286
- * of which a step can do.
454
+ * same reason {@link awaitTranscript} is: it waits, which only a body may do
455
+ * so it takes the `ctx`.
287
456
  *
288
457
  * The default is DELETE, which is what makes the timeout meaningful. A gate
289
458
  * whose no-answer branch keeps the data is not a gate — it is a prompt with a
@@ -294,25 +463,27 @@ export async function askWhetherToKeep(
294
463
  requestedBy: string,
295
464
  transcriptId: string,
296
465
  compensations: Compensation[],
466
+ ctx: WorkflowCtx,
297
467
  ): Promise<Retention> {
298
- // `using`, so the token is released when this scope exits — including the
299
- // timeout branch, where nothing ever arrives. A hook left registered holds
300
- // its token against the caller's NEXT run, which `getConflict()` below would
301
- // then report as a conflict.
302
- using decision = createHook<{ keep: boolean }>({ token: retentionToken(requestedBy) });
303
- // Claim the token BEFORE anyone is told to signal it. `createHook()` registers
304
- // nothing on its own — registration is committed when the workflow suspends —
305
- // so without this the caller's answer races a token no hook owns yet and is
306
- // answered "nobody is listening", which is indistinguishable from being late.
307
- await decision.getConflict();
308
-
309
- await note(
310
- `Recap ready. Keep the transcript on file, or delete it? Deleting in ${RETENTION_WINDOW} otherwise.`,
468
+ await ctx.step("noteGate", () =>
469
+ note(
470
+ "Recap ready. Keep the transcript on file, or delete it? Deleting in two minutes otherwise.",
471
+ ),
311
472
  );
312
- const answer = await Promise.race([decision, sleep(RETENTION_WINDOW).then(() => undefined)]);
473
+
474
+ // ONE call, not a race — and the ordering worry the DevKit version opened with
475
+ // is gone with it. `createHook()` registered nothing until the workflow
476
+ // suspended, so a caller's answer could reach a token no hook owned yet and be
477
+ // told "nobody is listening", indistinguishable from being late; hence the
478
+ // `getConflict()` claim on the line above it. `ctx.waitFor` registers the
479
+ // token BEFORE it suspends, by construction, because registering it is how it
480
+ // knows what to wait for.
481
+ const answer = await ctx.waitFor<{ keep: boolean }>(retentionToken(requestedBy), {
482
+ timeoutMs: RETENTION_WINDOW_MS,
483
+ });
313
484
 
314
485
  if (answer?.keep === true) return { kept: true, answered: true };
315
- await discardTranscript(transcriptId);
486
+ await ctx.step("discardOnDecline", () => discardTranscript(transcriptId));
316
487
  // Drop the undo now that the run has DONE what it undoes. Leaving it would be
317
488
  // harmless (`discardTranscript` treats a 404 as success, as every compensation
318
489
  // must) and would still be wrong to read: an unwind that reverses something
@@ -331,14 +502,26 @@ export async function askWhetherToKeep(
331
502
  * reported and stepped over rather than thrown. The `label` is what makes that
332
503
  * report actionable.
333
504
  */
334
- export async function compensate(compensations: Compensation[], because: string): Promise<void> {
505
+ export async function compensate(
506
+ compensations: Compensation[],
507
+ because: string,
508
+ ctx: WorkflowCtx,
509
+ ): Promise<void> {
335
510
  if (compensations.length === 0) return;
336
- await note(`Recap failed (${because}) undoing ${compensations.length} step(s).`);
511
+ // The narration is a STEP like every other, so an unwind interrupted by a
512
+ // crash resumes with the lines already said replayed from the journal rather
513
+ // than said twice. Each undo is a step too — registered as one by whoever
514
+ // stacked it — which is what makes an interrupted unwind resumable at all.
515
+ await ctx.step("noteUnwind", () =>
516
+ note(`Recap failed (${because}) — undoing ${compensations.length} step(s).`),
517
+ );
337
518
  for (const compensation of compensations) {
338
519
  try {
339
520
  await compensation.undo();
340
521
  } catch (err) {
341
- await note(`Could not undo ${compensation.label}: ${errorMessage(err)}`);
522
+ await ctx.step("noteUndoFailed", () =>
523
+ note(`Could not undo ${compensation.label}: ${errorMessage(err)}`),
524
+ );
342
525
  }
343
526
  }
344
527
  }
@@ -349,11 +532,26 @@ export async function compensate(compensations: Compensation[], because: string)
349
532
  * Hand the recording to the provider.
350
533
  *
351
534
  * Returns in milliseconds with a job id — the batch API's whole shape, and what
352
- * makes the poll below a real wait rather than a simulated one.
535
+ * makes the wait below a real wait rather than a simulated one.
536
+ *
537
+ * `webhookUrl` is where the provider should POST when the job settles, and
538
+ * {@link callbackUrl} is what produces it. The body passes the result IN rather
539
+ * than this minting it, which keeps every HTTP decision in a function a spec can
540
+ * call with a plain string — and keeps the mint in one place.
541
+ *
542
+ * **It answers `callback` as well as `id`, and that is a determinism
543
+ * requirement rather than a convenience.** Whether a callback was registered
544
+ * decides whether {@link awaitTranscript} parks on a hook, and a body may only
545
+ * branch on values that come out of the JOURNAL — so the fact is returned by the
546
+ * step that established it. Reading it in the body instead would re-evaluate it
547
+ * on every replay, and a redeploy that changed the deployment's public URL
548
+ * mid-run would flip the branch: the walk would then look for a `waitFor` the
549
+ * journal never recorded, or skip one it did.
353
550
  */
354
- export async function submitRecording(url: string): Promise<{ id: string }> {
355
- "use step";
356
-
551
+ export async function submitRecording(
552
+ url: string,
553
+ webhookUrl?: string,
554
+ ): Promise<{ id: string; callback: boolean }> {
357
555
  await report(`Submitting ${new URL(url).hostname} for transcription…`);
358
556
 
359
557
  // `stepTranscribeSubmitClassified` owns the endpoint, the raw-key auth, the
@@ -362,8 +560,56 @@ export async function submitRecording(url: string): Promise<{ id: string }> {
362
560
  // `throwStepError` already applied, so a provider refusal stays terminal and a
363
561
  // rate limit waits out the delay the provider itself named. `speaker_labels`
364
562
  // is this desk's own request, which is what `params` is for — the async API's
365
- // surface is large and the SDK deliberately does not mirror it.
366
- return await stepTranscribeSubmitClassified(url, { params: { speaker_labels: true } });
563
+ // surface is large and the SDK deliberately does not mirror it, and
564
+ // `webhook_url` is a second field on the same passthrough.
565
+ //
566
+ // No `omitUndefined` here, unlike `checkTranscript` below, and the difference
567
+ // is which boundary the value crosses: `params` is serialized, and
568
+ // `JSON.stringify` drops a property whose value is `undefined` — so an absent
569
+ // callback is an absent KEY on the wire, which is what the provider needs.
570
+ // What must not creep in is a `?? null` or a `?? ""` to "be explicit": either
571
+ // one puts the key back, and a provider handed a null for a URL is entitled to
572
+ // refuse the whole submission.
573
+ const job = await stepTranscribeSubmitClassified(url, {
574
+ params: { speaker_labels: true, webhook_url: webhookUrl },
575
+ });
576
+ return { id: job.id, callback: webhookUrl !== undefined };
577
+ }
578
+
579
+ /**
580
+ * The URL the provider should POST to when this run's transcript is ready, or
581
+ * `undefined` when this deployment cannot offer one.
582
+ *
583
+ * `stepWebhookUrl` (`@alexkroman1/aai/step`) is the step-side half of
584
+ * `ctx.workflows.publicWebhookUrl` — one concept, two surfaces — and it exists
585
+ * because a workflow body and the steps it calls are handed no `ToolContext`.
586
+ * Note it cannot be replaced by `requireStepEnv("AAI_PUBLIC_BASE_URL")`: the
587
+ * public base URL is a boot parameter of the DEPLOYMENT, living in the guest's
588
+ * exec env, while the step env is the tenant's own `.env` and
589
+ * `aai secret put` keys — so that read is `undefined` in production precisely
590
+ * where the value exists.
591
+ *
592
+ * **It THROWS rather than answering `undefined`, and catching it is the whole
593
+ * job of this function.** A callback URL has no legitimate default — it is
594
+ * either the one a third party can reach or it is a lie — so the SDK refuses to
595
+ * invent one. What a template must not do is let that throw reach the step: the
596
+ * recap would fail over a missing optimization. So the throw is converted to
597
+ * "no callback", which puts the run on the poll arm it used to be on always.
598
+ *
599
+ * The cases with no usable URL are a self-hosted server started without
600
+ * `publicUrl`, any spec (which publishes no minter), and **local development
601
+ * either way**: `aai dev`'s origin is a `localhost` one, so it either throws or
602
+ * mints a URL no third party can dial. So a local run always exercises the poll
603
+ * arm. Point a tunnel at the dev server's BACKEND port — the Vite port a
604
+ * developer opens does not proxy `/.well-known/` — and set `PUBLIC_URL` to it to
605
+ * exercise the callback at all.
606
+ */
607
+ export function callbackUrl(token: string): string | undefined {
608
+ try {
609
+ return stepWebhookUrl(token);
610
+ } catch {
611
+ return undefined;
612
+ }
367
613
  }
368
614
 
369
615
  /**
@@ -385,8 +631,6 @@ export async function submitRecording(url: string): Promise<{ id: string }> {
385
631
  * template.
386
632
  */
387
633
  export async function checkTranscript(id: string): Promise<TranscriptState> {
388
- "use step";
389
-
390
634
  const response = await request(`${TRANSCRIPT_ENDPOINT}/${id}`);
391
635
  const body = await response.json();
392
636
  const status = readString(body, "status");
@@ -426,8 +670,6 @@ export async function checkTranscript(id: string): Promise<TranscriptState> {
426
670
  * a replay is exactly that world.
427
671
  */
428
672
  export async function discardTranscript(id: string): Promise<void> {
429
- "use step";
430
-
431
673
  await report(`Discarding transcript ${id}.`);
432
674
  // Not through `request` above, because a 404 is a SUCCESS here — see below.
433
675
  // `stepFetch` for the same reason it does; only the status handling differs.
@@ -454,8 +696,6 @@ export async function discardTranscript(id: string): Promise<void> {
454
696
  * journal instead of submitting the recording again.
455
697
  */
456
698
  export async function summarize(url: string, transcript: TranscriptState): Promise<Recap> {
457
- "use step";
458
-
459
699
  await report("Writing the recap.");
460
700
 
461
701
  const text = (transcript.text ?? "").slice(0, MAX_TRANSCRIPT_CHARS);
@@ -489,9 +729,6 @@ export async function summarize(url: string, transcript: TranscriptState): Promi
489
729
  };
490
730
  }
491
731
 
492
- /** A rate limit — and a model that ignored the format — are both expected here. */
493
- summarize.maxRetries = 5;
494
-
495
732
  /**
496
733
  * Say one line into the run's progress channel.
497
734
  *
@@ -501,7 +738,6 @@ summarize.maxRetries = 5;
501
738
  * `recap_progress` is what reads it back down the phone.
502
739
  */
503
740
  export async function note(line: string): Promise<void> {
504
- "use step";
505
741
  await report(line);
506
742
  }
507
743
 
@@ -24,3 +24,24 @@
24
24
  export function retentionToken(sessionId: string): string {
25
25
  return `retention:${sessionId}`;
26
26
  }
27
+
28
+ /**
29
+ * The nudge that says a transcript is ready.
30
+ *
31
+ * The other end of this one is not a tool but ASSEMBLYAI: `request_recap` mints
32
+ * `ctx.workflows.publicWebhookUrl(transcriptToken(ctx.sessionId))` and hands it
33
+ * to the provider as `webhook_url`, and the provider's `POST` to that URL is
34
+ * what resolves the body's wait. So the two sides that have to agree are a file
35
+ * in this template and a third party on the public internet — which is the
36
+ * strongest case there is for deriving the string in one place.
37
+ *
38
+ * **A token is held for the life of its run and given back only when the run
39
+ * goes TERMINAL**, so deriving it from the session is what makes a second recap
40
+ * in the same call legal: `claimHook` refuses a token another run still holds,
41
+ * and a refusal is not a suspend, so it would unwind the saga and delete the
42
+ * transcript the run was waiting for. `request_recap` allows one LIVE run per
43
+ * caller, which is the invariant that keeps this safe.
44
+ */
45
+ export function transcriptToken(sessionId: string): string {
46
+ return `transcript:${sessionId}`;
47
+ }
@@ -25,7 +25,7 @@
25
25
  // evidence.
26
26
  //
27
27
  // WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
28
- // in the path, a `"use workflow"` body is an ordinary async function — no
28
+ // in the path, a workflow body is an ordinary async function — no
29
29
  // journal, no replay, and no per-step retry, so a rate-limited live run FAILS
30
30
  // where a deployed one would have ridden it out. The tier that really resumes a
31
31
  // run is `aai-cli`'s `dev-workflow.scenario.test.ts`.
@@ -17,10 +17,10 @@
17
17
  * of that lives.
18
18
  */
19
19
 
20
+ import { FatalError } from "@alexkroman1/aai/step-errors";
20
21
  import { parseSchemaInput, schemaInputIssues } from "@alexkroman1/aai/testing";
21
22
  import { installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
22
23
  import { beforeEach, describe, expect, test, vi } from "vitest";
23
- import { FatalError } from "workflow";
24
24
  import agentDef, { MAX_ROUNDS, redline } from "./agent.ts";
25
25
  import {
26
26
  briefBlock,