@alexkroman1/aai-cli 9.1.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 +720 -16
  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 +311 -38
  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
@@ -20,6 +20,7 @@ import { parseEnv } from "node:util";
20
20
  import {
21
21
  createAgentServer,
22
22
  ensureSessionStateSchema,
23
+ ensureWorkflowJournalSchema,
23
24
  withHostCredentialFallback,
24
25
  } from "@alexkroman1/aai-runtime";
25
26
  import { defaultClientDir } from "@alexkroman1/aai-ui/client-dir";
@@ -61,20 +62,6 @@ if (!existsSync(workerPath)) {
61
62
  const worker = await import(pathToFileURL(workerPath).href);
62
63
  const agent = worker.default;
63
64
 
64
- /**
65
- * The compiled workflow surface, carried on the bundle as two string exports.
66
- *
67
- * A `"use workflow"` body has to go through the DevKit's compiler, which happens
68
- * at BUILD time — a deployed agent is handed one ESM string and has no bundler —
69
- * so `aai build` leaves the result here as data. Passing them is what makes
70
- * durable workflows actually run: without them the server accepts a run and no
71
- * world is ever started to execute it, so it sits `pending` with no error
72
- * anywhere. Both are absent for a project with no `workflows/` directory.
73
- */
74
- const workflowCode =
75
- typeof worker.__aaiWorkflowCode === "string" ? worker.__aaiWorkflowCode : undefined;
76
- const stepCode = typeof worker.__aaiStepCode === "string" ? worker.__aaiStepCode : undefined;
77
-
78
65
  /**
79
66
  * Parse a dotenv-syntax file into a record; `{}` when it does not exist.
80
67
  *
@@ -167,6 +154,10 @@ const publicUrl = process.env.PUBLIC_URL?.trim();
167
154
  */
168
155
  if (env.DATABASE_URL) {
169
156
  await ensureSessionStateSchema({ url: env.DATABASE_URL, logger: console });
157
+ // And the durable-run journal's, which is a separate set of tables owned by
158
+ // the same deployment. Without it a project with a `DATABASE_URL` boots
159
+ // claiming durable runs and fails on the first one.
160
+ await ensureWorkflowJournalSchema({ url: env.DATABASE_URL, logger: console });
170
161
  }
171
162
 
172
163
  const server = createAgentServer({
@@ -178,16 +169,14 @@ const server = createAgentServer({
178
169
  providerEnv: withHostCredentialFallback(env),
179
170
  clientDir: resolveClientDir(),
180
171
  ...(publicUrl ? { publicUrl } : {}),
181
- // Durable workflows. A `DATABASE_URL` in `env` puts the runs in Postgres and
182
- // they survive a restart; without one they live in a per-process directory and
183
- // do not, which is the same trade `aai dev` makes.
172
+ // Durable workflows need nothing passed here. A `DATABASE_URL` in `env` puts
173
+ // the runs in Postgres and they survive a restart; without one they live in a
174
+ // per-process directory and do not, which is the same trade `aai dev` makes.
184
175
  //
185
- // Passed straight through rather than conditionally spread: both fields accept
186
- // `undefined` explicitly, so a project with no `workflows/` directory needs no
187
- // guard here and a truthiness-guarded spread is what `guard-invariants`
188
- // rule 22 counts.
189
- workflowCode,
190
- stepCode,
176
+ // Two options used to sit here the compiled workflow surface, carried on the
177
+ // bundle as `__aaiWorkflowCode`/`__aaiStepCode` because a `"use workflow"` body
178
+ // had to go through a compiler at BUILD time. The engine reads the agent's own
179
+ // `workflows` declaration instead, so there is no artifact to hand over.
191
180
  });
192
181
 
193
182
  // Loopback by default: this server has no request authentication of its own,
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { a as ok, n as fail } from "./_output-CKkmWs7i.mjs";
3
3
  import { n as log, s as unwrapCancel } from "./_ui-u7T4YooX.mjs";
4
- import { f as checkedResponse, p as isStringArray } from "./_studio-CKrsixd0.mjs";
5
- import { t as secretRequest } from "./_slug-api--ZBmIGH-.mjs";
4
+ import { f as checkedResponse, p as isStringArray } from "./_studio-B1waXMuR.mjs";
5
+ import { t as secretRequest } from "./_slug-api-CfGQq1Tt.mjs";
6
6
  import { isRecord } from "@alexkroman1/aai/utils";
7
7
  import * as p from "@clack/prompts";
8
8
  import { text } from "node:stream/consumers";
@@ -4,12 +4,13 @@ import { n as log, t as fmtUrl } from "./_ui-u7T4YooX.mjs";
4
4
  import { s as updateProjectConfig } from "./_config-CmJOFsAP.mjs";
5
5
  import { t as resolveServerEnv } from "./_server-common-6e0QI4mq.mjs";
6
6
  import { a as resolveDeployTarget } from "./_agent-BTwZJu4P.mjs";
7
- import { layerScaffold } from "./_templates-4WcKOjS5.mjs";
8
- import { a as publishStudioProject, c as studioProjectUrl, f as checkedResponse, i as projectNameFromDir, n as fetchStudioProject, o as pushStudioSource, r as listStudioProjects, s as studioProjectApiUrl, t as collectSourceFiles, u as apiRequest } from "./_studio-CKrsixd0.mjs";
7
+ import { layerScaffold } from "./_templates-0Zr2z6yA.mjs";
8
+ import { a as publishStudioProject, c as studioProjectUrl, f as checkedResponse, i as projectNameFromDir, n as fetchStudioProject, o as pushStudioSource, r as listStudioProjects, s as studioProjectApiUrl, t as collectSourceFiles, u as apiRequest } from "./_studio-B1waXMuR.mjs";
9
9
  import { existsSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { isRecord, omitUndefined } from "@alexkroman1/aai/utils";
12
12
  import { mkdir, readdir, writeFile } from "node:fs/promises";
13
+ import { isPathInside } from "@alexkroman1/aai-runtime/internal";
13
14
  //#region studio.ts
14
15
  /**
15
16
  * The studio-workspace commands: `aai list`, `aai pull`, `aai push`,
@@ -35,7 +36,7 @@ async function executeList(opts) {
35
36
  async function materializeFiles(dir, files) {
36
37
  const targets = Object.entries(files).map(([rel, content]) => {
37
38
  const abs = path.resolve(dir, rel);
38
- if (abs !== dir && !abs.startsWith(dir + path.sep)) throw new Error(`Pulled file path escapes the project directory: ${rel}`);
39
+ if (!isPathInside(dir, abs)) throw new Error(`Pulled file path escapes the project directory: ${rel}`);
39
40
  return {
40
41
  abs,
41
42
  content
@@ -45,7 +45,7 @@
45
45
  // * the VOICE — see `speakATone` below, which carries both reasons.
46
46
  //
47
47
  // WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
48
- // in the path, a `"use workflow"` body is an ordinary async function — no
48
+ // in the path, a workflow body is an ordinary async function — no
49
49
  // journal, no replay, and no per-step retry, so the resume-after-segment-27
50
50
  // property is NOT exercised and a rate-limited live run fails where a deployed
51
51
  // one would have ridden it out. `aai-cli`'s `dev-workflow.scenario.test.ts` is
@@ -25,7 +25,8 @@
25
25
 
26
26
  import { readdir } from "node:fs/promises";
27
27
  import { tmpdir } from "node:os";
28
- import { stubSpeech } from "@alexkroman1/aai/testing";
28
+ import { FatalError, RetryableError } from "@alexkroman1/aai/step-errors";
29
+ import { createWorkflowCtx, stubSpeech } from "@alexkroman1/aai/testing";
29
30
  import {
30
31
  installStubGateway,
31
32
  installStubReporter,
@@ -34,9 +35,14 @@ import {
34
35
  installStubUploads,
35
36
  } from "@alexkroman1/aai/testing/vitest";
36
37
  import { beforeEach, describe, expect, test, vi } from "vitest";
37
- import { FatalError, RetryableError } from "workflow";
38
38
  import agentDef, { audit } from "./agent.ts";
39
- import { joinSegments, now, SEGMENT_CONCURRENCY, transcribeSegment } from "./workflows/audit.ts";
39
+ import {
40
+ auditFlow,
41
+ joinSegments,
42
+ now,
43
+ SEGMENT_CONCURRENCY,
44
+ transcribeSegment,
45
+ } from "./workflows/audit.ts";
40
46
  import { analyse, ingestRecording } from "./workflows/ingest.ts";
41
47
  import {
42
48
  ANALYSIS_FORMAT,
@@ -626,11 +632,11 @@ describe("transcribing one segment", () => {
626
632
  });
627
633
 
628
634
  test("a rate limit is RETRYABLE, so one busy minute does not fail the run", async () => {
629
- // The expected failure of a 32-wide fan-out, and the reason this step's
630
- // `maxRetries` is above the default.
635
+ // The expected failure of a 32-wide fan-out, and the reason the BODY calls
636
+ // this step with more attempts than the default — asserted where the policy
637
+ // now lives, in `the whole run` below.
631
638
  stubProvider({ status: 429, message: "slow down" });
632
639
  await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).rejects.toBeInstanceOf(RetryableError);
633
- expect(transcribeSegment.maxRetries).toBe(5);
634
640
  });
635
641
 
636
642
  test("a rejected request is FATAL, so it is not asked five more times", async () => {
@@ -783,8 +789,6 @@ describe("the ffmpeg steps, up to the spawn", () => {
783
789
  // Fatal, not retryable: four more attempts find the same missing binary, and the
784
790
  // message already carries the install instructions.
785
791
  await expect(ingestRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
786
- // And the retry budget is still raised, for the I/O halves that ARE transient.
787
- expect(ingestRecording.maxRetries).toBe(5);
788
792
  });
789
793
 
790
794
  test("narrate speaks first, then fails fatally with no ffmpeg to master with", async () => {
@@ -815,3 +819,71 @@ describe("the ffmpeg steps, up to the spawn", () => {
815
819
  );
816
820
  });
817
821
  });
822
+
823
+ describe("the body's step policy", () => {
824
+ /**
825
+ * A `ctx` that walks the whole body without running a single step.
826
+ *
827
+ * `runSteps: false` plus one journaled result per step name: no ffmpeg, no
828
+ * provider, no model. Both specs below need the identical skeleton and each
829
+ * only reads `ctx.steps` afterwards, so it is built once.
830
+ */
831
+ const walkedCtx = () =>
832
+ createWorkflowCtx({
833
+ runSteps: false,
834
+ results: {
835
+ clockStart: 1000,
836
+ clockEnd: 4000,
837
+ ingestRecording: {
838
+ audio: "upl_pcm",
839
+ source: "call.wav",
840
+ codec: "pcm_s16le",
841
+ durationMs: 20_000,
842
+ bytes: 60 * BYTES_PER_SECOND,
843
+ silences: pauses(10, 20, 30),
844
+ loudness: MEASURED,
845
+ },
846
+ transcribeSegment: { index: 0, text: "hello" },
847
+ summarize: { headline: "H", risks: [], actions: [], spoken: "S." },
848
+ narrate: { audio: "upl_wav", durationMs: 500, bytes: 32 },
849
+ },
850
+ });
851
+
852
+ test("raises the attempt budget on both steps whose failure is transient I/O", async () => {
853
+ // The retry policy is an argument to `ctx.step` now rather than a
854
+ // `maxRetries` property, so the CALL is the only place it is observable —
855
+ // which is also the honest place for it, since the same function called from
856
+ // two sites may deserve different patience.
857
+ const ctx = walkedCtx();
858
+
859
+ await auditFlow({ recording: UPLOAD_ID }, ctx);
860
+
861
+ const budgets = new Map(ctx.steps.map((step) => [step.name, step.maxAttempts]));
862
+ // The EXACT number, not `toBeGreaterThan(3)`: the value is a literal in the
863
+ // body and a typo'd `maxAttempts: 4` is exactly what this should catch.
864
+ expect(budgets.get("ingestRecording")).toBe(6);
865
+ expect(budgets.get("transcribeSegment")).toBe(6);
866
+ // The clock and the two model-shaped steps take the default, which is the
867
+ // other half of the claim: a raised budget is a decision about ONE step.
868
+ // Asserted as PRESENT-with-no-budget rather than as `get(…) === undefined`,
869
+ // which a step the body never reached at all would also satisfy.
870
+ for (const name of ["clockStart", "summarize"]) {
871
+ expect(budgets.has(name)).toBe(true);
872
+ expect(budgets.get(name)).toBeUndefined();
873
+ }
874
+ });
875
+
876
+ test("reads the clock at each end under its own name, so a run's history is legible", async () => {
877
+ // `(name, occurrence)` would tell two `now` calls apart on its own
878
+ // (`now#0`, `now#1`); distinct names are for the person reading the history.
879
+ const ctx = walkedCtx();
880
+
881
+ const output = await auditFlow({ recording: UPLOAD_ID }, ctx);
882
+
883
+ expect(ctx.steps[0]?.name).toBe("clockStart");
884
+ expect(ctx.steps.at(-1)?.name).toBe("clockEnd");
885
+ // Subtracted in the BODY from two journaled values, so a replay reports the
886
+ // same elapsed rather than re-reading a clock.
887
+ expect(output.elapsedMs).toBe(3000);
888
+ });
889
+ });
@@ -22,6 +22,51 @@
22
22
  * lines here, because the two lines that matter are the ones around them: the
23
23
  * `URL.revokeObjectURL` on cleanup, and the guard that stops a slow first
24
24
  * download landing under a second run's output.
25
+ *
26
+ * ## A reload here costs the UPLOAD as well as the run
27
+ *
28
+ * A `runId` names a run for as long as something holds it, and this page held it
29
+ * in React state — so a refresh lost it while the desk carried on decoding,
30
+ * cutting and auditing. On this template that is the most expensive orphaning
31
+ * in `templates/`: the recording is already stored, so the work is paid for, and
32
+ * a page with no handle on it invites somebody to upload a 700 MB call a second
33
+ * time and run the whole pipeline again. `key` is the handle that survives a
34
+ * reload and `recover: true` is what reads it back.
35
+ *
36
+ * The upload half of a reload is already the SDK's: `useWorkflowSubmit`
37
+ * remembers the id it minted, so picking the same file again sends only the
38
+ * windows that did not land. What it needs from the page is the same LIFETIME
39
+ * on both halves, which is the first reason this desk takes `useRunKey()`'s
40
+ * default — `sessionStorage`, the same store the upload recall uses. A handle
41
+ * that outlived it would promise a return the other half cannot keep.
42
+ *
43
+ * ## Why the artifact being shareable does NOT make the key shareable
44
+ *
45
+ * This is the template where a `?key=` parameter is most tempting: what a run
46
+ * produces is an audit somebody wants to send a colleague, and a URL is how
47
+ * people send things. Weigh what the parameter would actually hand over, though,
48
+ * because there is no per-user filtering behind `find` — the key IS the access
49
+ * control, so anyone holding it gets, on this agent:
50
+ *
51
+ * - the full TRANSCRIPT of a recorded call, plus the risks and actions somebody
52
+ * had audited out of it,
53
+ * - the audio, which is a real person's voice, recorded with consent to record
54
+ * and not consent to circulate,
55
+ * - `cancel()` and `wake()` on a live run, and
56
+ * - every other run this desk has filed under the same key, not just the one
57
+ * that was shared.
58
+ *
59
+ * And a URL leaks by ordinary use: pasted into a chat, kept in history, sent as
60
+ * a referrer to whatever the page links out to. Against that, sharing the ARTIFACT
61
+ * needs none of it — the page renders the audit and offers `Download audit.mp3`,
62
+ * so a person sends the file and the findings deliberately, to exactly who they
63
+ * meant. A shareable key would trade a deliberate send for an accidental one, on
64
+ * the most sensitive input any template here accepts. It stays in
65
+ * `sessionStorage`, which covers the reload this section is about and dies with
66
+ * the tab.
67
+ *
68
+ * A key derived from the recording would be worse still: two desks auditing one
69
+ * call would recover each other's runs.
25
70
  */
26
71
 
27
72
  import "@alexkroman1/aai-ui/styles.css";
@@ -36,10 +81,12 @@ import {
36
81
  SubmitButton,
37
82
  UploadProgressBar,
38
83
  useDownloadUrl,
84
+ useRunKey,
39
85
  useWorkflowSubmit,
40
86
  WorkflowFields,
41
87
  WorkflowProgress,
42
88
  } from "@alexkroman1/aai-ui";
89
+ import { useState } from "react";
43
90
  import type { audit } from "./agent.ts";
44
91
 
45
92
  /**
@@ -59,6 +106,20 @@ const WORKFLOW = "audit";
59
106
  */
60
107
  const api = createWorkflowApi();
61
108
 
109
+ /**
110
+ * What the desk says while a run is in flight — three situations, one line
111
+ * each.
112
+ *
113
+ * The reload case gets its own words deliberately: somebody who did not press
114
+ * the button is owed an explanation for an audit appearing in front of them,
115
+ * and it is the line that keeps them from uploading the call again.
116
+ */
117
+ function pendingNote(startedHere: boolean, found: boolean): string {
118
+ if (startedHere) return "Reloading is safe — this page will find the audit again.";
119
+ if (!found) return "Looking for an audit this tab started earlier…";
120
+ return "Still auditing a call this tab uploaded earlier — no need to send it again.";
121
+ }
122
+
62
123
  /** One labelled number in the pipeline panel. */
63
124
  function Stat({ label, value }: { label: string; value: string }) {
64
125
  return (
@@ -85,11 +146,20 @@ function Findings({ title, items }: { title: string; items: string[] }) {
85
146
  }
86
147
 
87
148
  export function App() {
149
+ // This tab's handle on its own audits, in the store the upload recall uses —
150
+ // see the module doc for why a `?key=` is the wrong trade on this desk.
151
+ const key = useRunKey();
152
+ // Did THIS load start the run? A reload cannot have, and that is the only way
153
+ // the page can tell "auditing what you just uploaded" from "picking up where
154
+ // you left off" — the hook reports the run, not who asked for it.
155
+ const [startedHere, setStartedHere] = useState(false);
88
156
  // The generic is what makes `run.status === "completed"` narrow to a TYPED
89
- // `run.output` instead of `unknown`.
157
+ // `run.output` instead of `unknown`. Neither half of the recovery is useful
158
+ // alone: without `key` there is nothing to find the run by, and without
159
+ // `recover` the key is only ever written.
90
160
  const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error } = useWorkflowSubmit<
91
161
  typeof audit
92
- >(WORKFLOW, { api });
162
+ >(WORKFLOW, { api, key, recover: true });
93
163
  const output = run?.status === "completed" ? run.output : undefined;
94
164
  // `useDownloadUrl` is the SDK's: the byte route takes the agent's bearer, so the
95
165
  // bytes have to be FETCHED and handed to the element as an object URL — and the
@@ -106,7 +176,14 @@ export function App() {
106
176
  </p>
107
177
  </header>
108
178
 
109
- <Form onSubmit={submitForm} error={error} className="flex flex-col gap-4">
179
+ <Form
180
+ onSubmit={(values) => {
181
+ setStartedHere(true);
182
+ return submitForm(values);
183
+ }}
184
+ error={error}
185
+ className="flex flex-col gap-4"
186
+ >
110
187
  {/* Every control, from the workflow's own input schema. See the module doc. */}
111
188
  <WorkflowFields workflow={WORKFLOW} />
112
189
  <SubmitButton pending={pending} pendingLabel="Auditing…">
@@ -114,6 +191,13 @@ export function App() {
114
191
  </SubmitButton>
115
192
  </Form>
116
193
 
194
+ {/* `pending` covers the RUN rather than the request, and on a reload it is
195
+ also true while the run is being looked up by key — the stretch where
196
+ an empty form would invite a second 700 MB upload of the same call. */}
197
+ {pending && (
198
+ <p className="text-sm opacity-70">{pendingNote(startedHere, run !== undefined)}</p>
199
+ )}
200
+
117
201
  {/* The upload is its own wait, and the one nothing else can describe: the run
118
202
  does not EXIST until the bytes are in, so there is no run id and nothing
119
203
  for `<WorkflowProgress>` to read. */}
@@ -34,17 +34,18 @@
34
34
  *
35
35
  * ## The plan is made in the BODY, and that is legal
36
36
  *
37
- * `planSegments` runs in the directive body rather than in a step, which looks
38
- * like a rule violation and is not: it is a pure function of `ingested.silences`
39
- * and `ingested.durationMs`, both of which came out of a journaled step result.
40
- * So a replay re-derives the identical list in the identical order, which is
41
- * exactly what `mapConcurrent` needs — the DevKit correlates a journal entry to a
42
- * step call by the ORDER the call was issued in.
37
+ * `planSegments` runs in the BODY rather than in a step, which looks like a rule
38
+ * violation and is not: it is a pure function of `ingested.silences` and
39
+ * `ingested.durationMs`, both of which came out of a journaled step result. So a
40
+ * replay re-derives the identical list in the identical order, which is exactly
41
+ * what `mapConcurrent` needs — every call shares the name `transcribeSegment`, so
42
+ * a journal entry is matched by the ORDER its call was issued in.
43
43
  *
44
44
  * Putting it in a step would journal the same list twice (once as part of the
45
45
  * ingest result, once as the plan) and buy nothing.
46
46
  */
47
47
 
48
+ import type { WorkflowCtx } from "@alexkroman1/aai";
48
49
  import { encodeWav, mapConcurrent, readUpload, report } from "@alexkroman1/aai/step";
49
50
  import { countWords, formatDuration } from "@alexkroman1/aai/utils";
50
51
  // ERASED at build time, so the body can name the schema's own output type without
@@ -79,15 +80,18 @@ import { transcribeSpan } from "./sync-api.ts";
79
80
  * the byte bound never binds and what is left is the endpoint's own knee, which it
80
81
  * measured at 32. Its `BYTES_IN_FLIGHT` and `MAX_SEGMENT_CONCURRENCY` docs carry
81
82
  * both measurements; this is the one number that survives them. *
82
- * **What EXECUTES at this width is the world's call, not this number's.**
83
+ * **What EXECUTES at this width is the engine's call, not this number's.**
83
84
  * `mapConcurrent` bounds how many step calls the body has in flight; how many
84
- * run at once is the workflow world's worker concurrency, which on the
85
- * `DATABASE_URL` path defaults to three so on a default deployment a width
86
- * above three is inert while still costing a queued job per item. That makes
87
- * this the FAR SIDE's knee and the width to use once an operator has raised
88
- * the ceiling, not a promise about a stock deployment. See "The WINDOW is not
89
- * the concurrency" in `@alexkroman1/aai/step`'s `mapConcurrent`; the numbers
90
- * above were measured against the endpoint and say nothing about that layer.
85
+ * run at once is `DEFAULT_STEP_CONCURRENCY` (`aai-runtime`), which is **16** —
86
+ * measured against a real microVM at Modal's guaranteed reservation, where a
87
+ * concurrent segment of 48 kHz stereo costs 26.1 MB. So a width above 16 is
88
+ * inert on a stock deployment while still costing a queued job per item, and
89
+ * this number is the FAR SIDE's knee: the one to use once an operator has
90
+ * raised `AAI_WORKFLOW_STEP_CONCURRENCY` for a larger guest. It was three,
91
+ * inherited from graphile-worker and never measured, which made every number
92
+ * in the table above unreachable. See "The WINDOW is not the concurrency" in
93
+ * `@alexkroman1/aai/step`'s `mapConcurrent`; the numbers above were measured
94
+ * against the endpoint and say nothing about that layer.
91
95
  */
92
96
  export const SEGMENT_CONCURRENCY = 32;
93
97
 
@@ -150,14 +154,30 @@ export type CallAudit = {
150
154
  * The input is what `POST /workflows/runs` carries — see `agent.ts` for the schema
151
155
  * it is validated against before a run exists.
152
156
  */
153
- export async function auditFlow(input: WorkflowInputOf<typeof audit>): Promise<CallAudit> {
154
- "use workflow";
155
-
157
+ export async function auditFlow(
158
+ input: WorkflowInputOf<typeof audit>,
159
+ ctx: WorkflowCtx,
160
+ ): Promise<CallAudit> {
156
161
  // Both at once: neither needs the other, and issued together they are one round
157
162
  // trip instead of two before any audio moves. The ORDER is still a pure function
158
163
  // of this expression — the two calls go out synchronously, left to right — which
159
164
  // is what a replay reproduces.
160
- const [startedAt, ingested] = await Promise.all([now(), ingestRecording(input.recording)]);
165
+ //
166
+ // `clockStart` and `clockEnd` are two NAMES for one function, deliberately.
167
+ // `(name, occurrence)` step identity would tell two `now` calls apart on its
168
+ // own (`now#0`, `now#1`), but a run's history is read by a person: `clockEnd`
169
+ // says which end it is where `now#1` makes the reader count call sites.
170
+ // `maxAttempts: 6` was `ingestRecording.maxRetries = 5` — five retries AFTER
171
+ // the first, so six in all. More than the default 3, and not because a
172
+ // conversion is flaky: a corrupt file fails identically forever, and
173
+ // `throwFfmpegStepError` is what stops the engine retrying that. It is the two
174
+ // I/O halves that are worth another attempt — the step reads a whole recording
175
+ // out of the store and writes a whole one back, and either can lose a
176
+ // connection on a file this size.
177
+ const [startedAt, ingested] = await Promise.all([
178
+ ctx.step("clockStart", () => now()),
179
+ ctx.step("ingestRecording", () => ingestRecording(input.recording), { maxAttempts: 6 }),
180
+ ]);
161
181
 
162
182
  // Pure, in the body, from journaled values. See the module doc. Planned against
163
183
  // the stored BYTE COUNT rather than the reported duration — `durationSeconds`
@@ -169,14 +189,24 @@ export async function auditFlow(input: WorkflowInputOf<typeof audit>): Promise<C
169
189
  // already journaled, so a resume replays those for free and re-issues only what
170
190
  // is missing — where catching here to salvage a partial transcript would return
171
191
  // a recording with a silent hole in it and report success.
192
+ // `mapConcurrent` hands out items from a monotonic cursor, so the Nth call
193
+ // ISSUED is segment N whatever order they settle in — which is what makes
194
+ // `transcribeSegment#N` stable across a replay. `maxAttempts: 6` was
195
+ // `transcribeSegment.maxRetries = 5` — more than the default 3 because a rate
196
+ // limit is the expected failure here, and a segment that 429s is not a segment
197
+ // that is wrong.
172
198
  const parts = await mapConcurrent(segments, SEGMENT_CONCURRENCY, (segment) =>
173
- transcribeSegment(ingested.audio, segment),
199
+ ctx.step("transcribeSegment", () => transcribeSegment(ingested.audio, segment), {
200
+ maxAttempts: 6,
201
+ }),
174
202
  );
175
203
 
176
204
  const transcript = joinSegments(segments, parts);
177
- const summary = await summarize(transcript, ingested.source, ingested.durationMs);
178
- const spoken = await narrate(summary.spoken, input.voice);
179
- const finishedAt = await now();
205
+ const summary = await ctx.step("summarize", () =>
206
+ summarize(transcript, ingested.source, ingested.durationMs),
207
+ );
208
+ const spoken = await ctx.step("narrate", () => narrate(summary.spoken, input.voice));
209
+ const finishedAt = await ctx.step("clockEnd", () => now());
180
210
 
181
211
  // Whatever this returns is what a caller reads as `output` on a completed run —
182
212
  // so it is what the page renders, typed through `WorkflowOutputOf`. Assembled in
@@ -221,8 +251,6 @@ export async function auditFlow(input: WorkflowInputOf<typeof audit>): Promise<C
221
251
  * no reason for a second copy of it to exist.
222
252
  */
223
253
  export async function transcribeSegment(audioId: string, segment: Segment): Promise<SegmentText> {
224
- "use step";
225
-
226
254
  // One line per segment, which is what makes the fan-out legible to a page: the
227
255
  // status is `running` for the whole thing, so without this a sixty-segment
228
256
  // recording and a one-segment recording look identical while they run.
@@ -245,12 +273,6 @@ export async function transcribeSegment(audioId: string, segment: Segment): Prom
245
273
  return { index: segment.index, text };
246
274
  }
247
275
 
248
- /**
249
- * Retries beyond the default 3, because a rate limit is the expected failure and a
250
- * segment that 429s is not a segment that is wrong.
251
- */
252
- transcribeSegment.maxRetries = 5;
253
-
254
276
  /**
255
277
  * When it is now, as epoch ms.
256
278
  *
@@ -266,8 +288,6 @@ transcribeSegment.maxRetries = 5;
266
288
  * smaller thing.
267
289
  */
268
290
  export async function now(): Promise<number> {
269
- "use step";
270
-
271
291
  return Date.now();
272
292
  }
273
293
 
@@ -63,7 +63,7 @@
63
63
  import { readFile, stat } from "node:fs/promises";
64
64
  import { join } from "node:path";
65
65
  import { probeMedia, runFfmpeg } from "@alexkroman1/aai/ffmpeg";
66
- import { pcmDurationMs, report, uploadInfo } from "@alexkroman1/aai/step";
66
+ import { pcmDurationMs, report, requireCompleteUpload } from "@alexkroman1/aai/step";
67
67
  import { throwFatalStepError, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
68
68
  import { readUploadToFile, withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
69
69
  import { formatBytes, formatDuration, plural } from "@alexkroman1/aai/utils";
@@ -120,10 +120,21 @@ export type Ingested = {
120
120
  * normalization writes a file, so journaling the id means a resumed run reads the
121
121
  * file that already exists instead of paying to make a second one.
122
122
  */
123
+ // This file sits in `scripts/coverage-per-file-baseline.json` at 46.8%, and the
124
+ // reason is worth having in place. What is uncovered is `ingestRecording`'s HAPPY
125
+ // path — three ffmpeg invocations — which needs a real binary and so belongs to
126
+ // the scenario tier, not here; the two failure paths ARE covered, and every
127
+ // decision the step makes lives in `media.ts` as a pure function at 95%.
128
+ //
129
+ // It measured exactly 50.0% before the DevKit removal, and the two statements it
130
+ // lost were `"use step";` and `ingestRecording.maxRetries = 5;` — both of which
131
+ // the two failure tests EXECUTED, so both counted as covered while testing
132
+ // nothing. Removing them is what took the file under the floor: a directive
133
+ // propping a coverage number up is the least useful statement in the tree.
123
134
  export async function ingestRecording(uploadId: string): Promise<Ingested> {
124
- "use step";
125
-
126
- const stored = await uploadInfo(uploadId);
135
+ // `requireCompleteUpload`, not `uploadInfo`: `size` is the readable PREFIX, so
136
+ // an upload still arriving would be copied short and levelled as the whole call.
137
+ const stored = await requireCompleteUpload(uploadId);
127
138
  await report(`Reading ${stored.name || uploadId} (${formatBytes(stored.size)}).`);
128
139
 
129
140
  return await withTempDir(
@@ -206,17 +217,6 @@ export async function ingestRecording(uploadId: string): Promise<Ingested> {
206
217
  );
207
218
  }
208
219
 
209
- /**
210
- * Retries beyond the default 3.
211
- *
212
- * Not because a conversion is flaky — a corrupt file fails identically forever,
213
- * and `throwFfmpegStepError` is what stops the DevKit retrying that. It is the
214
- * two I/O halves that are worth another attempt: this step reads a whole
215
- * recording out of the store and writes a whole one back, and either can lose a
216
- * connection on a file this size.
217
- */
218
- ingestRecording.maxRetries = 5;
219
-
220
220
  /**
221
221
  * Run a `media.ts` reader, turning "I cannot read this analysis" into a terminal
222
222
  * failure.
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * No directive in this file, which is what lets it sit under `workflows/`: the
7
7
  * Workflow DevKit's builder scans this directory and transforms only what carries
8
- * a `"use workflow"` / `"use step"` body. Everything here is a pure function of a
8
+ * a workflow body or a step. Everything here is a pure function of a
9
9
  * journaled value, and that is deliberate rather than tidy — **an ffmpeg pipeline
10
10
  * is untestable exactly where it spawns**, so every decision this desk makes is
11
11
  * pushed out of the steps and into this module, where a spec drives it with no
@@ -105,8 +105,6 @@ export async function summarize(
105
105
  source: string,
106
106
  durationMs: number,
107
107
  ): Promise<CallSummary> {
108
- "use step";
109
-
110
108
  await report("Reading the transcript.");
111
109
  const reply = await stepGenerateJsonClassified(
112
110
  `Audit this transcript of a recorded call (${source}, ${formatDuration(durationMs)}).\n\n` +
@@ -161,8 +159,6 @@ export async function narrate(
161
159
  script: string,
162
160
  voice?: string,
163
161
  ): Promise<{ audio: string; durationMs: number; bytes: number }> {
164
- "use step";
165
-
166
162
  const spoken = await stepSpeak(script, omitUndefined({ voice }));
167
163
 
168
164
  return await withTempDir(
@@ -18,17 +18,19 @@
18
18
  // each one is answered in memory. It proves the wiring, not the summary.
19
19
  //
20
20
  // WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
21
- // in the path, a `"use workflow"` body is an ordinary async function — no
21
+ // in the path, a workflow body is an ordinary async function — no
22
22
  // journal, no replay, no retry, and the `sleep` is RECORDED rather than taken.
23
23
  // `run.slept` below is that admission written as an assertion. The tier that
24
24
  // really suspends and resumes a run is `aai-cli`'s
25
25
  // `dev-workflow.scenario.test.ts`.
26
+
26
27
  import { createServer, type Server } from "node:http";
27
28
  import type { AddressInfo } from "node:net";
28
29
  import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
29
30
  import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
30
31
  import { expect, onTestFinished } from "vitest";
31
32
  import agentDef, { digest } from "./agent.ts";
33
+ import { SETTLE_MS } from "./workflows/digest.ts";
32
34
 
33
35
  /** The gateway leg, so one handler can route the page and the model apart. */
34
36
  const isModelCall = (url: string): boolean => url.includes("/chat/completions");
@@ -150,7 +152,7 @@ describeWorkflowEval(agentDef, (test) => {
150
152
  expect(run.reported).toContain("Filing the digest.");
151
153
  // The one thing this harness cannot do, stated as an assertion rather than
152
154
  // left implied: the durable wait was ASKED FOR and not taken.
153
- expect(run.slept).toEqual([{ duration: "10 seconds" }]);
155
+ expect(run.slept).toEqual([{ duration: SETTLE_MS }]);
154
156
  });
155
157
 
156
158
  test("fails terminally on a page with no readable text", async ({ app, mode }) => {