@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
@@ -39,7 +39,7 @@
39
39
  * ## Batch polling, which is this file's one genuinely new mechanism
40
40
  *
41
41
  * `spoken-summary` and `transcription-workflow` each wait for ONE transcript,
42
- * so their poll loop is `for (…) { if (done) return; await sleep(…) }`. Here N
42
+ * so their poll loop is `for (…) { if (done) return; await ctx.sleep(…) }`. Here N
43
43
  * episodes are in flight at once and they finish out of order, so the loop has
44
44
  * to carry a shrinking pending set and let the finished ones drop out — see
45
45
  * {@link waitForTranscripts}. It is the same idea one dimension up, and the
@@ -49,15 +49,16 @@
49
49
  * @module digest
50
50
  */
51
51
 
52
+ import type { WorkflowCtx } from "@alexkroman1/aai";
52
53
  import { mapConcurrent, report, TRANSCRIBE_API } from "@alexkroman1/aai/step";
53
54
  import {
55
+ FatalError,
54
56
  stepGenerateJsonClassified,
55
57
  stepTranscribePollClassified,
56
58
  stepTranscribeSubmitClassified,
57
59
  } from "@alexkroman1/aai/step-errors";
58
60
  import { errorMessage } from "@alexkroman1/aai/utils";
59
61
  import type { WorkflowInputOf } from "@alexkroman1/aai/workflow-api";
60
- import { FatalError, sleep } from "workflow";
61
62
  import { z } from "zod";
62
63
  import type { dailyDigest } from "../agent.ts";
63
64
  import { discoverEpisodes, type Episode } from "./feeds.ts";
@@ -70,7 +71,7 @@ import { sendDigestToSlack } from "./slack.ts";
70
71
  * restating the number — an eval skips the sleep, so the duration asked for is
71
72
  * the only observable there is.
72
73
  */
73
- export const POLL_DELAY = "20 seconds";
74
+ export const POLL_DELAY_MS = 20_000;
74
75
 
75
76
  /**
76
77
  * 180 rounds x 20s = an hour, which is far past any podcast episode.
@@ -93,15 +94,17 @@ const MAX_TRANSCRIPT_CHARS = 18_000;
93
94
  /**
94
95
  * Submissions in flight at once — polite to the provider, still parallel.
95
96
  *
96
- * Inside the workflow world's default worker concurrency (three on the
97
- * `DATABASE_URL` path), so this width is what really runs — see "The WINDOW is
98
- * not the concurrency" in `mapConcurrent`.
97
+ * Inside `DEFAULT_STEP_CONCURRENCY` (`aai-runtime`, 16), so this width is what
98
+ * really runs — see "The WINDOW is not the concurrency" in `mapConcurrent`.
99
99
  */
100
100
  const SUBMIT_CONCURRENCY = 2;
101
101
 
102
- /** Status checks in flight at once. Cheaper calls, so a wider gate — and three is
103
- * also where the world's default worker concurrency caps it, so this is the
104
- * widest a stock deployment executes. */
102
+ /**
103
+ * Status checks in flight at once. Cheaper calls, so a wider gate.
104
+ *
105
+ * Well inside `DEFAULT_STEP_CONCURRENCY` (`aai-runtime`, 16), like
106
+ * {@link SUBMIT_CONCURRENCY} above, so this width is what really runs.
107
+ */
105
108
  const POLL_CONCURRENCY = 3;
106
109
 
107
110
  /**
@@ -173,9 +176,10 @@ const SummaryReply = z.object({
173
176
  * that would answer differently on the second pass — {@link timestamp} is a step
174
177
  * for exactly that reason.
175
178
  */
176
- export async function dailyDigestFlow(input: DigestInput): Promise<DailyDigestOutput> {
177
- "use workflow";
178
-
179
+ export async function dailyDigestFlow(
180
+ input: DigestInput,
181
+ ctx: WorkflowCtx,
182
+ ): Promise<DailyDigestOutput> {
179
183
  // No `??` fallbacks: {@link DigestInput} is the schema's OUTPUT, so every
180
184
  // `.default()` has already run by the time a run reaches this line. The
181
185
  // chain this replaces restated all four of them, which is a second place for
@@ -188,26 +192,42 @@ export async function dailyDigestFlow(input: DigestInput): Promise<DailyDigestOu
188
192
  let digestsSent = 0;
189
193
 
190
194
  for (let digestNumber = 1; digestNumber <= totalDigests; digestNumber += 1) {
191
- const episodes = await discoverEpisodes(input.podcastChannels, maxEpisodes);
192
- const jobs = await mapConcurrent(episodes, SUBMIT_CONCURRENCY, submitTranscript);
193
- const transcripts = await waitForTranscripts(jobs);
194
- const digests = await mapConcurrent(transcripts, SUBMIT_CONCURRENCY, summarizeTranscript);
195
-
196
- const slackStatus = await sendDigestToSlack({
197
- slackWebhookUrl: input.slackWebhookUrl,
198
- slackWorkflowTextParam: input.slackWorkflowTextParam,
199
- podcastChannels: input.podcastChannels,
195
+ const episodes = await ctx.step("discoverEpisodes", () =>
196
+ discoverEpisodes(input.podcastChannels, maxEpisodes),
197
+ );
198
+ // `maxAttempts` was a `maxRetries` property on each function (4, 4, 5 —
199
+ // retries AFTER the first attempt, so 5, 5, 6 in all). It is an argument to
200
+ // the CALL now, which is where a policy belongs: the same function called
201
+ // from two places may deserve different patience.
202
+ const jobs = await mapConcurrent(episodes, SUBMIT_CONCURRENCY, (episode) =>
203
+ ctx.step("submitTranscript", () => submitTranscript(episode), { maxAttempts: 5 }),
204
+ );
205
+ const transcripts = await waitForTranscripts(jobs, ctx);
206
+ const digests = await mapConcurrent(transcripts, SUBMIT_CONCURRENCY, (transcript) =>
207
+ ctx.step("summarizeTranscript", () => summarizeTranscript(transcript), { maxAttempts: 6 }),
208
+ );
209
+
210
+ const slackStatus = await ctx.step("postDigest", () =>
211
+ sendDigestToSlack({
212
+ slackWebhookUrl: input.slackWebhookUrl,
213
+ slackWorkflowTextParam: input.slackWorkflowTextParam,
214
+ podcastChannels: input.podcastChannels,
215
+ episodes: digests,
216
+ digestNumber,
217
+ totalDigests,
218
+ }),
219
+ );
220
+
221
+ lastDigest = {
222
+ sentAt: await ctx.step("timestamp", () => timestamp()),
223
+ slackStatus,
200
224
  episodes: digests,
201
- digestNumber,
202
- totalDigests,
203
- });
204
-
205
- lastDigest = { sentAt: await timestamp(), slackStatus, episodes: digests };
225
+ };
206
226
  digestsSent += 1;
207
227
 
208
228
  // Not after the last one: a run that has delivered everything it owes
209
229
  // should end, not sleep for a day and then end.
210
- if (digestNumber < totalDigests) await sleep(intervalMs);
230
+ if (digestNumber < totalDigests) await ctx.sleep(intervalMs);
211
231
  }
212
232
 
213
233
  return {
@@ -223,10 +243,10 @@ export async function dailyDigestFlow(input: DigestInput): Promise<DailyDigestOu
223
243
  /**
224
244
  * Wait for a whole BATCH of transcripts, letting them finish out of order.
225
245
  *
226
- * A plain async function rather than a step, and not because it is small: it
227
- * calls steps and it `sleep`s, neither of which a step may do. So it runs as
228
- * part of the body and is replayed with it — legal for the ordinary reason,
229
- * that every line is either a step call or a `sleep`.
246
+ * Part of the BODY rather than a step, and not because it is small: it CALLS
247
+ * steps and it sleeps, neither of which may happen inside one. So it is replayed
248
+ * with the body — legal for the ordinary reason, that every line is either a
249
+ * `ctx.step` or a `ctx.sleep` and it takes the `ctx` for that reason.
230
250
  *
231
251
  * The shape to notice is that `pending` SHRINKS. A loop that waited for all N
232
252
  * on every round would hold the whole batch hostage to its slowest member, and
@@ -247,19 +267,24 @@ export async function dailyDigestFlow(input: DigestInput): Promise<DailyDigestOu
247
267
  * the top. Found by `agent.eval.test.ts`, which is the only tier that can see
248
268
  * it, a per-step spec having no batch to order.
249
269
  */
250
- async function waitForTranscripts(jobs: TranscriptJob[]): Promise<TranscriptState[]> {
270
+ async function waitForTranscripts(
271
+ jobs: TranscriptJob[],
272
+ ctx: WorkflowCtx,
273
+ ): Promise<TranscriptState[]> {
251
274
  let pending = jobs;
252
275
  // Keyed by episode id rather than appended, and that is what keeps the digest
253
276
  // in PUBLICATION order — see this function's doc.
254
277
  const settled = new Map<string, TranscriptState>();
255
278
 
256
279
  for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS && pending.length > 0; attempt += 1) {
257
- const polled = await mapConcurrent(pending, POLL_CONCURRENCY, pollTranscript);
280
+ const polled = await mapConcurrent(pending, POLL_CONCURRENCY, (job) =>
281
+ ctx.step("pollTranscript", () => pollTranscript(job), { maxAttempts: 5 }),
282
+ );
258
283
  for (const state of polled) {
259
284
  if (state.transcriptStatus !== "submitted") settled.set(state.id, state);
260
285
  }
261
286
  pending = polled.filter((state) => state.transcriptStatus === "submitted");
262
- if (pending.length > 0) await sleep(POLL_DELAY);
287
+ if (pending.length > 0) await ctx.sleep(POLL_DELAY_MS);
263
288
  }
264
289
 
265
290
  for (const job of pending) settled.set(job.id, gaveUpOn(job));
@@ -305,8 +330,6 @@ function gaveUpOn(job: TranscriptJob): TranscriptState {
305
330
  * one-second default instead.
306
331
  */
307
332
  export async function submitTranscript(episode: Episode): Promise<TranscriptJob> {
308
- "use step";
309
-
310
333
  await report(`Submitting ${episode.title} for transcription.`);
311
334
  try {
312
335
  const { id } = await stepTranscribeSubmitClassified(episode.audioUrl, {
@@ -320,8 +343,6 @@ export async function submitTranscript(episode: Episode): Promise<TranscriptJob>
320
343
  }
321
344
  }
322
345
 
323
- submitTranscript.maxRetries = 4;
324
-
325
346
  /**
326
347
  * Ask once whether one job has finished.
327
348
  *
@@ -332,8 +353,6 @@ submitTranscript.maxRetries = 4;
332
353
  * for "still going".
333
354
  */
334
355
  export async function pollTranscript(job: TranscriptJob): Promise<TranscriptState | TranscriptJob> {
335
- "use step";
336
-
337
356
  if (job.transcriptStatus === "unavailable") return job;
338
357
 
339
358
  try {
@@ -358,12 +377,8 @@ export async function pollTranscript(job: TranscriptJob): Promise<TranscriptStat
358
377
  }
359
378
  }
360
379
 
361
- pollTranscript.maxRetries = 4;
362
-
363
380
  /** Reduce one transcript to the summary and points the digest carries. */
364
381
  export async function summarizeTranscript(state: TranscriptState): Promise<EpisodeDigest> {
365
- "use step";
366
-
367
382
  if (state.transcriptStatus === "unavailable") {
368
383
  // Still an entry in the digest. A reader who sees four summaries and one
369
384
  // stated reason knows what happened; four summaries and silence looks like
@@ -404,17 +419,21 @@ export async function summarizeTranscript(state: TranscriptState): Promise<Episo
404
419
  };
405
420
  }
406
421
 
407
- summarizeTranscript.maxRetries = 5;
408
-
409
422
  /**
410
423
  * The clock, as a step.
411
424
  *
412
425
  * A step's result is journaled and therefore stable across replays, where
413
426
  * `new Date()` in the body would answer differently on every one — and a body
414
- * that is not deterministic is a body the DevKit cannot replay.
427
+ * that is not deterministic is a body the engine cannot replay.
428
+ *
429
+ * The read below is therefore a BASELINED occurrence of `guard-invariants`
430
+ * rule 30, and that is the reason: it is inside a step, not inside a body. The
431
+ * rule bans the call anywhere in a shipped `workflows/` file because the
432
+ * `ctx.step` callback boundary is not decidable from a line; `dailyDigestFlow`
433
+ * is what reaches this one, as `ctx.step("timestamp", () => timestamp())`.
434
+ * Anything at BODY level is the bug, not an exception.
415
435
  */
416
436
  export async function timestamp(): Promise<string> {
417
- "use step";
418
437
  return new Date().toISOString();
419
438
  }
420
439
 
@@ -45,9 +45,8 @@
45
45
  */
46
46
 
47
47
  import { report } from "@alexkroman1/aai/step";
48
- import { stepFetchOk } from "@alexkroman1/aai/step-errors";
48
+ import { FatalError, stepFetchOk } from "@alexkroman1/aai/step-errors";
49
49
  import { decodeHtmlEntities, isRecord, omitUndefined, safeJsonParse } from "@alexkroman1/aai/utils";
50
- import { FatalError } from "workflow";
51
50
 
52
51
  /** How long any one of these lookups may take before it is a failure. */
53
52
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -93,8 +92,6 @@ export async function discoverEpisodes(
93
92
  podcastChannels: string,
94
93
  maxEpisodes: number,
95
94
  ): Promise<Episode[]> {
96
- "use step";
97
-
98
95
  await report("Finding recent podcast episodes.");
99
96
  const links = parsePodcastChannels(podcastChannels);
100
97
  if (links.length === 0) throw new FatalError("Add at least one podcast link.");
@@ -19,10 +19,11 @@
19
19
  *
20
20
  * ## What a step still owns
21
21
  *
22
- * The `"use step"` stays HERE, and it has to. The Workflow DevKit's builder
23
- * rewrites step bodies it finds in this directory; one written inside the SDK
24
- * would be transformed by nothing, so it would run inline with no journal and
25
- * no retry while looking durable at the call site.
22
+ * The step BOUNDARY is not here only a body holds a `ctx`, and the call this
23
+ * file is reached through is `ctx.step("postDigest", …)` in `digest.ts`. What
24
+ * stays here is what a step DOES: the digest rendered as a `ChannelMessage` and
25
+ * one `sendToChannelClassified` call. Deciding which steps exist is the
26
+ * template's job; what happens inside one is the SDK's.
26
27
  */
27
28
 
28
29
  import { type ChannelMessage, slackChannel } from "@alexkroman1/aai/channels";
@@ -51,8 +52,6 @@ export type SlackDigestInput = {
51
52
  * `RetryableError` carrying Slack's own `Retry-After` on a 5xx.
52
53
  */
53
54
  export async function sendDigestToSlack(input: SlackDigestInput): Promise<string> {
54
- "use step";
55
-
56
55
  await report("Posting the digest to Slack.");
57
56
  return await sendToChannelClassified(
58
57
  slackChannel({ webhookUrl: input.slackWebhookUrl, textParam: input.slackWorkflowTextParam }),
@@ -16,12 +16,33 @@
16
16
  // `sleep()` is RECORDED rather than taken — so the poll loop below runs at
17
17
  // full speed and the only way to observe a run in flight is to hold the
18
18
  // provider's answer, which is what `hold` does.
19
- // * **`createHook()` throws untransformed**, so the RETENTION GATE — this
20
- // template's headline port of Temporal's `expense` — cannot be reached at
21
- // all here, and neither can `keep_transcript`'s signal: a run that gets past
22
- // `summarize` fails on the hook. Nothing below asserts the gate, its three
23
- // outcomes, or its safe default; `agent.test.ts` drives
24
- // `askWhetherToKeep` directly for those, and it is the only tier that can.
19
+ // * **Nobody can answer a hook here**, which reaches the RETENTION GATE — this
20
+ // template's headline port of Temporal's `expense` — through exactly ONE of
21
+ // its three branches. `ctx.waitFor` carrying a `timeoutMs` resolves
22
+ // `undefined` in this engine, which IS the closed window, so every run that
23
+ // gets past `summarize` takes the gate's safe default: it deletes the
24
+ // transcript it made and completes. That branch is asserted below. The two
25
+ // ANSWERED branches are the unreachable ones — `signal()` answers `false`
26
+ // for every token, there being nothing in process to deliver a payload — so
27
+ // an approval, a decline and `keep_transcript` itself are `agent.test.ts`'s
28
+ // to drive against `askWhetherToKeep`, and it is the only tier that can.
29
+ // * **Every run here takes the NO-CALLBACK arm, and that must not be read as
30
+ // coverage of the webhook path.** The eval publishes no webhook minter, so
31
+ // `stepWebhookUrl` throws, the template's `callbackUrl` degrades to
32
+ // `undefined`, the job is submitted with no `webhook_url`, and
33
+ // `awaitTranscript` never parks on a hook at all — it polls, exactly as it
34
+ // did before the provider could call back. That is worth having rather than
35
+ // working around: it is the arm a deployment with no public URL takes, it is
36
+ // the arm a dropped delivery lands on, and it is the one that must never
37
+ // hang. A case below asserts it POSITIVELY — that the submitted job carries
38
+ // no `webhook_url` — so an eval runtime that later publishes a minter fails
39
+ // here instead of silently changing what all of these cases measure.
40
+ //
41
+ // What no eval here can show is a delivery RESUMING a run, so no case claims
42
+ // it. The answered arm is `agent.test.ts`'s, through
43
+ // `createWorkflowCtx({ hooks })`, which is the only tier that can send a
44
+ // payload at all; a real HTTP POST to the public callback route is
45
+ // `aai-cli`'s `dev-workflow.scenario.test.ts`'s, and is not yet written.
25
46
  // * **The provider is scripted, in BOTH modes**, through `stepFetch`'s
26
47
  // published slot — so the transcription, the recap's model call and the
27
48
  // compensating DELETE are all deterministic and free, while the SESSION's
@@ -186,7 +207,10 @@ const START_TURN = [
186
207
  * Not tidiness: the scripted provider is unpublished when the test that
187
208
  * installed it finishes, so a body still mid-flight would make its next request
188
209
  * against whatever the next case publishes — or against the real provider, with
189
- * a real key. Every run here ends FAILED, on `createHook()`; see the header.
210
+ * a real key. A run drained here COMPLETES rather than failing, and the last
211
+ * thing it does on the way is delete its own transcript: the retention gate's
212
+ * window closes with nobody having answered, which is the safe default. See the
213
+ * header, and the case that pins it.
190
214
  */
191
215
  async function drain(workflows: EvalWorkflows | undefined, provider: ScriptedProvider) {
192
216
  provider.release();
@@ -226,10 +250,18 @@ describeEval(
226
250
  expect(posts).toHaveLength(1);
227
251
  // And it submitted the recording the DESK supplies, because a phone
228
252
  // caller cannot read a URL aloud.
229
- expect(JSON.parse(String(posts[0]?.body))).toMatchObject({
253
+ const submitted = JSON.parse(String(posts[0]?.body));
254
+ expect(submitted).toMatchObject({
230
255
  audio_url: SAMPLE_RECORDING,
231
256
  speaker_labels: true,
232
257
  });
258
+ // NO `webhook_url`, asserted positively — see the third boundary in this
259
+ // file's header. Nothing publishes a webhook minter here, so
260
+ // `stepWebhookUrl` throws, `callbackUrl` degrades, and every run in this
261
+ // file polls rather than parking on a callback. Pinning it here is what
262
+ // stops an eval runtime that later publishes one from silently changing
263
+ // which arm all of these cases measure.
264
+ expect(Object.keys(submitted)).not.toContain("webhook_url");
233
265
 
234
266
  await drain(workflows, provider);
235
267
  },
@@ -328,6 +360,38 @@ describeEval(
328
360
  },
329
361
  );
330
362
 
363
+ test("nobody answers the retention gate, so the transcript is not kept", async ({
364
+ workflows,
365
+ }) => {
366
+ // The gate's SAFE DEFAULT, and the branch of it this tier really does
367
+ // reach: `ctx.waitFor` here carries a `timeoutMs` and no one can send a
368
+ // payload, which is the closed window rather than a missing feature. So
369
+ // this is the ordinary ending of every run in this file, and it is worth
370
+ // an assertion of its own — a gate whose no-answer branch KEPT the data
371
+ // would be a prompt with a grace period, and nothing else here would
372
+ // notice the difference.
373
+ const provider = stubProvider();
374
+
375
+ const run = await workflows?.run(recap, {
376
+ url: SAMPLE_RECORDING,
377
+ requestedBy: "eval-session",
378
+ });
379
+
380
+ expect(run?.status).toBe("completed");
381
+ // `answered: false` is the half that separates this from a caller who
382
+ // said no: the desk reports which of the two happened rather than only
383
+ // what it did.
384
+ expect(run?.output).toMatchObject({ kept: false, answered: false });
385
+ // And the default really is DELETE — the same request the compensation
386
+ // makes, reached by the opposite path: this run succeeded.
387
+ expect(requests(provider, "DELETE").map((one) => one.url)).toEqual([
388
+ `https://api.assemblyai.com/v2/transcript/${TRANSCRIPT_ID}`,
389
+ ]);
390
+ // The caller was ASKED first, which is what makes two minutes of silence
391
+ // an answer at all.
392
+ expect(run?.reported.join("\n")).toMatch(/Keep the transcript on file/);
393
+ });
394
+
331
395
  test("a run that fails after creating a transcript deletes it again", async ({ workflows }) => {
332
396
  // Started from the CASE rather than through a tool, because the subject
333
397
  // is the saga and the failure has to be injected: the provider refuses