@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.
- package/dist/{_bundler-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
- package/dist/_bundler.d.ts +3 -41
- package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
- package/dist/_dev-server.d.ts +1 -8
- package/dist/_dev-vite-config.d.ts +21 -0
- package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
- package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
- package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
- package/dist/_templates.d.ts +29 -0
- package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
- package/dist/cli.mjs +46 -22
- package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
- package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
- package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
- package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
- package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
- package/dist/scaffold/CLAUDE.md +162 -54
- package/dist/scaffold/package.json +4 -6
- package/dist/scaffold/server.mjs +12 -23
- package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
- package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
- package/dist/templates/call-audit/agent.eval.test.ts +1 -1
- package/dist/templates/call-audit/agent.test.ts +80 -8
- package/dist/templates/call-audit/client.tsx +87 -3
- package/dist/templates/call-audit/workflows/audit.ts +52 -32
- package/dist/templates/call-audit/workflows/ingest.ts +15 -15
- package/dist/templates/call-audit/workflows/media.ts +1 -1
- package/dist/templates/call-audit/workflows/summarize.ts +0 -4
- package/dist/templates/link-digest/agent.eval.test.ts +4 -2
- package/dist/templates/link-digest/agent.test.ts +25 -5
- package/dist/templates/link-digest/client.tsx +65 -7
- package/dist/templates/link-digest/workflows/digest.ts +39 -29
- package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
- package/dist/templates/podcast-digest/agent.test.ts +108 -2
- package/dist/templates/podcast-digest/client.tsx +89 -8
- package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
- package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
- package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
- package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
- package/dist/templates/recap-workflow/agent.test.ts +323 -83
- package/dist/templates/recap-workflow/shared.ts +4 -3
- package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
- package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
- package/dist/templates/redline/agent.eval.test.ts +1 -1
- package/dist/templates/redline/agent.test.ts +1 -1
- package/dist/templates/redline/client.tsx +83 -3
- package/dist/templates/redline/workflows/redline.ts +26 -23
- package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
- package/dist/templates/research-workflow/agent.test.ts +32 -6
- package/dist/templates/research-workflow/agent.ts +4 -4
- package/dist/templates/research-workflow/shared.ts +4 -3
- package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
- package/dist/templates/research-workflow/workflows/research.ts +35 -38
- package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +10 -8
- package/dist/templates/spoken-summary/client.tsx +72 -3
- package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
- package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
- package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
- package/dist/templates/transcription-workflow/agent.test.ts +626 -14
- package/dist/templates/transcription-workflow/agent.ts +6 -4
- package/dist/templates/transcription-workflow/client.tsx +106 -308
- package/dist/templates/transcription-workflow/recover.test.ts +141 -0
- package/dist/templates/transcription-workflow/recover.ts +130 -0
- package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
- package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
- package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
- package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
- package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
- package/dist/templates/transcription-workflow/workflows/stream.ts +177 -24
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
- package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
- package/dist/worker-bundler.d.ts +0 -14
- package/dist/worker-bundler.mjs +149 -1
- package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
- package/dist/workflow.d.ts +1 -0
- package/package.json +4 -6
- package/dist/_workflow-scan.d.ts +0 -108
- package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
- package/dist/workflow-bundler.d.ts +0 -138
- package/dist/{_studio-CKrsixd0.mjs → _studio-B1waXMuR.mjs} +1 -1
package/dist/scaffold/server.mjs
CHANGED
|
@@ -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
|
|
182
|
-
// they survive a restart; without one they live in a
|
|
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
|
-
//
|
|
186
|
-
// `
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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-
|
|
5
|
-
import { t as secretRequest } from "./_slug-api
|
|
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-
|
|
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-
|
|
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 (
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
630
|
-
//
|
|
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
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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(
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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,
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
const stored = await
|
|
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
|
|
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
|
|
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:
|
|
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 }) => {
|