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