@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,19 +13,25 @@
|
|
|
13
13
|
* The workflow BODY is not tested here: it is only durable once the Workflow
|
|
14
14
|
* DevKit's build has transformed it, so a unit test of it would exercise a plain
|
|
15
15
|
* async function and prove nothing about replay. Its STEPS are, and directly —
|
|
16
|
-
*
|
|
16
|
+
* a step is an ordinary exported async function, so
|
|
17
17
|
* async function, so its HTML handling, its JSON contract with the model and its
|
|
18
18
|
* `FatalError` guards are all testable.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { schemaInputIssues } from "@alexkroman1/aai/testing";
|
|
21
|
+
import { createWorkflowCtx, schemaInputIssues } from "@alexkroman1/aai/testing";
|
|
22
22
|
import {
|
|
23
23
|
installStubStepFetch,
|
|
24
24
|
installStubGateway as stubGateway,
|
|
25
25
|
} from "@alexkroman1/aai/testing/vitest";
|
|
26
26
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
27
27
|
import agentDef, { digest } from "./agent.ts";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
digestFlow,
|
|
30
|
+
extractText,
|
|
31
|
+
extractTitle,
|
|
32
|
+
fetchArticle,
|
|
33
|
+
summarize,
|
|
34
|
+
} from "./workflows/digest.ts";
|
|
29
35
|
|
|
30
36
|
describe("the agent declares itself a workflow app", () => {
|
|
31
37
|
test("under the name the page starts a run by", () => {
|
|
@@ -208,7 +214,21 @@ describe("summarize", () => {
|
|
|
208
214
|
await expect(summarize(ARTICLE)).rejects.toThrow(/ASSEMBLYAI_API_KEY/);
|
|
209
215
|
});
|
|
210
216
|
|
|
211
|
-
test("
|
|
212
|
-
|
|
217
|
+
test("is called with more attempts than the default, because a rate limit and a bad format both happen", async () => {
|
|
218
|
+
// The retry policy is an argument to `ctx.step` now, not a `maxRetries`
|
|
219
|
+
// property on the function — so the assertion is about the BODY's call,
|
|
220
|
+
// which is the only place the policy is observable at all. `runSteps: false`
|
|
221
|
+
// because the subject is the declared policy rather than the work: the steps
|
|
222
|
+
// would otherwise need a page and a model.
|
|
223
|
+
const ctx = createWorkflowCtx({ runSteps: false });
|
|
224
|
+
await digestFlow({ url: "https://example.com/a" }, ctx);
|
|
225
|
+
|
|
226
|
+
const summarizeStep = ctx.steps.find((step) => step.name === "summarize");
|
|
227
|
+
expect(summarizeStep?.maxAttempts).toBeGreaterThan(3);
|
|
228
|
+
// The order is the body's, and it is worth pinning beside the policy: the
|
|
229
|
+
// fetch is separate from the model call precisely so a rate-limited
|
|
230
|
+
// summarize replays the fetch from the journal instead of hitting a
|
|
231
|
+
// stranger's server again.
|
|
232
|
+
expect(ctx.steps.map((step) => step.name)).toEqual(["fetchArticle", "summarize", "file"]);
|
|
213
233
|
});
|
|
214
234
|
});
|
|
@@ -9,8 +9,30 @@
|
|
|
9
9
|
*
|
|
10
10
|
* What replaces `useSession()` is `useWorkflowSubmit()`: it starts the run,
|
|
11
11
|
* follows its STATUS, and hands back the controls bound to it — `wake`, `cancel`
|
|
12
|
-
* and `reset`.
|
|
13
|
-
*
|
|
12
|
+
* and `reset`.
|
|
13
|
+
*
|
|
14
|
+
* ## The run survives a reload; the run ID does not
|
|
15
|
+
*
|
|
16
|
+
* A `runId` names a run for as long as anything is holding it, and this page
|
|
17
|
+
* holds it in React state — so a refresh loses it while the run carries on
|
|
18
|
+
* without it. That is the wrong half of durability to leave to the reader: the
|
|
19
|
+
* page used to promise "the run continues without it" and then had no way back
|
|
20
|
+
* to the run it was promising about.
|
|
21
|
+
*
|
|
22
|
+
* `key` is the handle that survives, and `recover` is what reads it back. Two
|
|
23
|
+
* lines of wiring, one decision:
|
|
24
|
+
*
|
|
25
|
+
* - **The key is OPAQUE and lives in `sessionStorage`.** It names nothing about
|
|
26
|
+
* the person or the link — `useRunKey()` is the SDK's, and its module argues
|
|
27
|
+
* what that rules out (a key derived from the URL being digested, a `?key=`
|
|
28
|
+
* parameter) and why. Six templates had written those twenty lines each.
|
|
29
|
+
* - **`recover: true`** makes the hook ask `find("digest", key)` as it mounts
|
|
30
|
+
* and follow whatever run comes back, so a reload lands back on the same
|
|
31
|
+
* headline, the same progress log and the same buttons.
|
|
32
|
+
*
|
|
33
|
+
* Deployed, this needs the correlation-key index, which is a `DATABASE_URL`
|
|
34
|
+
* away — `agent.ts` says what happens without one (the runs are still durable;
|
|
35
|
+
* the index that finds them by key is in memory).
|
|
14
36
|
*
|
|
15
37
|
* ## The FORM here is still written by hand, deliberately
|
|
16
38
|
*
|
|
@@ -31,13 +53,16 @@
|
|
|
31
53
|
* cheap: one stream each, ended by the agent when there is nothing left to say.
|
|
32
54
|
*
|
|
33
55
|
* Progress also REPLAYS — chunks are retained with the run — so a reload mid-run
|
|
34
|
-
* catches up rather than starting from whatever arrives next.
|
|
56
|
+
* catches up rather than starting from whatever arrives next. That only pays off
|
|
57
|
+
* because the reload can name its run again: `<WorkflowProgress runId>` is handed
|
|
58
|
+
* `run?.runId`, so before `recover` a refresh replayed a log for nobody.
|
|
59
|
+
* `lines={1}` is
|
|
35
60
|
* what narrows it to the newest line, because on a page this small that is the
|
|
36
61
|
* whole of what a status wants; `transcription-workflow` renders the full log,
|
|
37
62
|
* where a fan-out makes the history worth seeing.
|
|
38
63
|
*/
|
|
39
64
|
|
|
40
|
-
import { page, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
|
|
65
|
+
import { page, useRunKey, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
|
|
41
66
|
import "@alexkroman1/aai-ui/styles.css";
|
|
42
67
|
// ERASED at build time, so naming the agent's own type costs the browser bundle
|
|
43
68
|
// nothing — and it is what stops this file restating a shape `workflows/
|
|
@@ -45,20 +70,52 @@ import "@alexkroman1/aai-ui/styles.css";
|
|
|
45
70
|
import { useState } from "react";
|
|
46
71
|
import type { digest } from "./agent.ts";
|
|
47
72
|
|
|
73
|
+
/**
|
|
74
|
+
* What the page says while something is in flight — three situations, one line
|
|
75
|
+
* each, and none of them the sentence this page used to print.
|
|
76
|
+
*
|
|
77
|
+
* That one was "You can close this tab — the run continues without it": true
|
|
78
|
+
* about the run and false about the page, which is the worst shape a reassurance
|
|
79
|
+
* can have. The run did continue and the tab could never find it again. Now it
|
|
80
|
+
* can, so the promise gets stronger and the reload case gets its own words —
|
|
81
|
+
* somebody who did not press the button is owed an explanation for the work
|
|
82
|
+
* appearing in front of them.
|
|
83
|
+
*/
|
|
84
|
+
function pendingNote(startedHere: boolean, found: boolean): string {
|
|
85
|
+
if (startedHere)
|
|
86
|
+
return "You can close this tab or reload it — this page will find the run again.";
|
|
87
|
+
if (!found) return "Looking for a digest this tab started earlier…";
|
|
88
|
+
return "Still working on the digest this tab started earlier. Reloading is safe.";
|
|
89
|
+
}
|
|
90
|
+
|
|
48
91
|
export function App() {
|
|
49
92
|
const [url, setUrl] = useState("");
|
|
93
|
+
// This tab's handle on its own runs — minted once and remembered, which is
|
|
94
|
+
// what a later load produces to find the run again.
|
|
95
|
+
const key = useRunKey();
|
|
96
|
+
// Did THIS load start the run? A reload cannot have, and that is the only way
|
|
97
|
+
// the page can tell "working on what you just submitted" from "picking up
|
|
98
|
+
// where you left off" — the hook reports the run, not who asked for it.
|
|
99
|
+
const [startedHere, setStartedHere] = useState(false);
|
|
50
100
|
// The generic is what makes `run.status === "completed"` narrow to a TYPED
|
|
51
101
|
// `run.output` instead of `unknown`. `error` is the agent's own sentence for a
|
|
52
102
|
// rejected input, which is better copy than anything this page could write, and
|
|
53
103
|
// `wake` is bound to whatever run the hook is following — the whole reason this
|
|
54
104
|
// page no longer holds a `createWorkflowApi()` of its own.
|
|
55
|
-
const { submit, run, pending, error, wake } = useWorkflowSubmit<typeof digest>("digest"
|
|
105
|
+
const { submit, run, pending, error, wake } = useWorkflowSubmit<typeof digest>("digest", {
|
|
106
|
+
// Recorded with the run, and read back on the next load. Neither half is
|
|
107
|
+
// useful alone: without the key there is nothing to find the run by, and
|
|
108
|
+
// without `recover` the key is only ever written.
|
|
109
|
+
key,
|
|
110
|
+
recover: true,
|
|
111
|
+
});
|
|
56
112
|
|
|
57
113
|
// `submit()` resolves as soon as the run exists — deliberately not when it
|
|
58
114
|
// finishes. That is the whole mechanism: the digest sleeps for a while, and
|
|
59
115
|
// this page is free to be closed in the meantime.
|
|
60
116
|
const onSubmit = (event: React.FormEvent) => {
|
|
61
117
|
event.preventDefault();
|
|
118
|
+
setStartedHere(true);
|
|
62
119
|
void submit({ url });
|
|
63
120
|
};
|
|
64
121
|
|
|
@@ -90,8 +147,9 @@ export function App() {
|
|
|
90
147
|
|
|
91
148
|
{/* A run that has not settled says so. `pending` is not derivable from the
|
|
92
149
|
snapshot alone — an id the agent never knew leaves `run` undefined,
|
|
93
|
-
which would otherwise read as "still waiting" forever
|
|
94
|
-
|
|
150
|
+
which would otherwise read as "still waiting" forever, and on a reload
|
|
151
|
+
it is also true while the run is being looked up by key. */}
|
|
152
|
+
{pending && <p>{pendingNote(startedHere, run !== undefined)}</p>}
|
|
95
153
|
|
|
96
154
|
{/* The run's own narration, newest line only. `lines={1}` is the window;
|
|
97
155
|
everything else — the replay, and the "serves no stream" case that is
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright 2026 the AAI authors. MIT license.
|
|
2
2
|
/**
|
|
3
|
-
* The durable half of the link digest: a
|
|
4
|
-
*
|
|
3
|
+
* The durable half of the link digest: a workflow body and its steps, and the
|
|
4
|
+
* steps really read the page and really call a model.
|
|
5
5
|
*
|
|
6
6
|
* The rules are the same ones `research-workflow/workflows/research.ts` spells out
|
|
7
7
|
* — the body is replayed from the top on every resume, so it holds no live
|
|
@@ -24,14 +24,19 @@
|
|
|
24
24
|
* fetched text crosses a queue between them, which is what the cap on it is for.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import type { WorkflowCtx } from "@alexkroman1/aai";
|
|
27
28
|
import { report } from "@alexkroman1/aai/step";
|
|
28
|
-
import { stepFetchOk, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
|
|
29
|
+
import { FatalError, stepFetchOk, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
|
|
29
30
|
import { decodeHtmlEntities } from "@alexkroman1/aai/utils";
|
|
30
|
-
import { FatalError, sleep } from "workflow";
|
|
31
31
|
import { z } from "zod";
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* How long the digest sits before it is filed, so the wait is visible in dev.
|
|
35
|
+
*
|
|
36
|
+
* Milliseconds. `ctx.sleep` takes a number or a `Date` and no duration STRING —
|
|
37
|
+
* one more parser to own, and no call site in the repo passed one.
|
|
38
|
+
*/
|
|
39
|
+
export const SETTLE_MS = 10_000;
|
|
35
40
|
|
|
36
41
|
/**
|
|
37
42
|
* Characters of article text carried between the two steps.
|
|
@@ -86,31 +91,36 @@ export type Article = {
|
|
|
86
91
|
* literally the page's render model, and `WorkflowOutputOf<typeof digest>` in
|
|
87
92
|
* `client.tsx` is that type, derived rather than restated.
|
|
88
93
|
*/
|
|
89
|
-
export async function digestFlow(input: { url: string }) {
|
|
90
|
-
"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
export async function digestFlow(input: { url: string }, ctx: WorkflowCtx) {
|
|
95
|
+
const article = await ctx.step("fetchArticle", () => fetchArticle(input.url));
|
|
96
|
+
// `maxAttempts: 6` was `summarize.maxRetries = 5` — five retries AFTER the
|
|
97
|
+
// first attempt, so six in all. The retry policy moved from a property on the
|
|
98
|
+
// function to the CALL, which is where it belongs: the same function called
|
|
99
|
+
// from two places may deserve different patience, and a property could not say
|
|
100
|
+
// so. A rate limit and a model that ignored the format are both expected here.
|
|
101
|
+
const digest = await ctx.step("summarize", () => summarize(article), { maxAttempts: 6 });
|
|
94
102
|
|
|
95
103
|
// Suspended, not blocked: the sandbox is free to exit here and the run
|
|
96
|
-
// resumes when it comes due. Nothing about the code changes if it is
|
|
97
|
-
//
|
|
104
|
+
// resumes when it comes due. Nothing about the code changes if it is six
|
|
105
|
+
// hours — which is the interesting version, and the one that makes an
|
|
98
106
|
// overnight digest a digest rather than a slow request.
|
|
99
|
-
await sleep(
|
|
107
|
+
await ctx.sleep(SETTLE_MS);
|
|
100
108
|
|
|
101
|
-
|
|
109
|
+
const filedAt = await ctx.step("file", () => file(digest));
|
|
110
|
+
return { ...digest, filedAt };
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
/**
|
|
105
114
|
* Read the page.
|
|
106
115
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
116
|
+
* An ORDINARY exported async function — what makes it a step is the
|
|
117
|
+
* `ctx.step("fetchArticle", …)` that calls it, so it runs once per successful
|
|
118
|
+
* execution and its result is journaled; a replay returns that result instead of
|
|
119
|
+
* fetching again, which matters here more than usual because the far side is
|
|
120
|
+
* somebody else's web server. Being ordinary is also what lets `agent.test.ts`
|
|
121
|
+
* call it directly with no engine in the path.
|
|
110
122
|
*/
|
|
111
123
|
export async function fetchArticle(url: string): Promise<Article> {
|
|
112
|
-
"use step";
|
|
113
|
-
|
|
114
124
|
const { hostname } = new URL(url);
|
|
115
125
|
await report(`Reading ${hostname}…`);
|
|
116
126
|
|
|
@@ -124,7 +134,7 @@ export async function fetchArticle(url: string): Promise<Article> {
|
|
|
124
134
|
// `stepFetchOk` rather than `stepFetch` + an `ok` check: it makes the
|
|
125
135
|
// retryable/terminal split for us — a 404 or a 403 answers the same way on
|
|
126
136
|
// the fourth attempt, while a rate limit is exactly what retries are for, and
|
|
127
|
-
// its `Retry-After` reaches the
|
|
137
|
+
// its `Retry-After` reaches the engine's schedule instead of the default
|
|
128
138
|
// backoff. It also puts the server's own error text in the message.
|
|
129
139
|
const response = await stepFetchOk(url, {
|
|
130
140
|
// Some sites answer a bare request with a challenge page; asking for HTML
|
|
@@ -156,8 +166,6 @@ export async function fetchArticle(url: string): Promise<Article> {
|
|
|
156
166
|
* whole Node runtime is available here, unlike in the body.
|
|
157
167
|
*/
|
|
158
168
|
export async function summarize(article: Article): Promise<Digest> {
|
|
159
|
-
"use step";
|
|
160
|
-
|
|
161
169
|
await report("Pulling out the claims worth keeping.");
|
|
162
170
|
|
|
163
171
|
// `stepGenerateJsonClassified` unwraps the fence a model puts around JSON,
|
|
@@ -183,9 +191,6 @@ export async function summarize(article: Article): Promise<Digest> {
|
|
|
183
191
|
};
|
|
184
192
|
}
|
|
185
193
|
|
|
186
|
-
/** A rate limit — and a model that ignored the format — are both expected here. */
|
|
187
|
-
summarize.maxRetries = 5;
|
|
188
|
-
|
|
189
194
|
/**
|
|
190
195
|
* File the digest.
|
|
191
196
|
*
|
|
@@ -193,11 +198,16 @@ summarize.maxRetries = 5;
|
|
|
193
198
|
* replays the expensive half for free and re-issues only the cheap one.
|
|
194
199
|
* Returning the timestamp rather than reading a clock in the BODY is the same
|
|
195
200
|
* rule — a step's result is journaled and therefore stable across replays,
|
|
196
|
-
* where
|
|
201
|
+
* where the same read in the body would change on every one.
|
|
202
|
+
*
|
|
203
|
+
* The `new Date()` below is therefore a BASELINED occurrence of
|
|
204
|
+
* `guard-invariants` rule 30, and this is the reason: it is inside a step, not
|
|
205
|
+
* inside a body. The rule bans a clock read anywhere in a shipped `workflows/`
|
|
206
|
+
* file because the `ctx.step` callback boundary is not decidable from a line;
|
|
207
|
+
* `digestFlow` is what reaches this one, as `ctx.step("file", () =>
|
|
208
|
+
* file(digest))`. Anything at BODY level is the bug, not an exception.
|
|
197
209
|
*/
|
|
198
210
|
export async function file(_digest: Digest): Promise<string> {
|
|
199
|
-
"use step";
|
|
200
|
-
|
|
201
211
|
await report("Filing the digest.");
|
|
202
212
|
// A real desk would write the digest to its database here. The stub writes
|
|
203
213
|
// nothing, which is what the `_` says — and it is a stub because `ctx.db` is
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
//
|
|
33
33
|
// WHAT NO EVAL HERE COVERS: durability — which for THIS template is most of
|
|
34
34
|
// what it is for. Imported through vitest with no bundler in the path, a
|
|
35
|
-
//
|
|
35
|
+
// workflow body is an ordinary async function, so the multi-day
|
|
36
36
|
// suspension that makes a digest arrive tomorrow is not exercised; the sleep is
|
|
37
37
|
// RECORDED and skipped. `run.slept` below is that admission written as an
|
|
38
38
|
// assertion, and it is the only way to check a seven-day schedule without
|
|
@@ -43,7 +43,7 @@ import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
|
|
|
43
43
|
import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
44
44
|
import { expect } from "vitest";
|
|
45
45
|
import agentDef, { dailyDigest } from "./agent.ts";
|
|
46
|
-
import { MAX_POLL_ATTEMPTS,
|
|
46
|
+
import { MAX_POLL_ATTEMPTS, POLL_DELAY_MS, scheduleIntervalMs } from "./workflows/digest.ts";
|
|
47
47
|
|
|
48
48
|
/** The feed every case reads. Not a real host — nothing here leaves the process. */
|
|
49
49
|
const FEED_URL = "https://feeds.example.test/rebuild.xml";
|
|
@@ -320,9 +320,9 @@ describeWorkflowEval(agentDef, (test) => {
|
|
|
320
320
|
expect(world.calls.filter((call) => call.url.includes("/v2/transcript/"))).toHaveLength(5);
|
|
321
321
|
// Three waits for four rounds — asked for, and recorded rather than taken.
|
|
322
322
|
expect(run.slept).toEqual([
|
|
323
|
-
{ duration:
|
|
324
|
-
{ duration:
|
|
325
|
-
{ duration:
|
|
323
|
+
{ duration: POLL_DELAY_MS },
|
|
324
|
+
{ duration: POLL_DELAY_MS },
|
|
325
|
+
{ duration: POLL_DELAY_MS },
|
|
326
326
|
]);
|
|
327
327
|
|
|
328
328
|
// And the digest is in PUBLICATION order, not completion order. The feed is
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - The DECLARATION — the config a deploy validates and the schema a `start()`
|
|
11
11
|
* is checked against.
|
|
12
12
|
* - The PURE helpers, pulled out of the flow for exactly this reason.
|
|
13
|
-
* - The STEPS, directly.
|
|
13
|
+
* - The STEPS, directly. A step is an ordinary exported async function, so one
|
|
14
14
|
* function is an ordinary async function, so its HTTP handling, its
|
|
15
15
|
* partial-failure policy and its `FatalError` guards are all reachable —
|
|
16
16
|
* `installStubStepFetch` answers the network and `stubGateway` answers the model.
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* that fails to transcribe taking the whole digest down with it.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { parseSchemaInput, schemaInputIssues } from "@alexkroman1/aai/testing";
|
|
24
|
+
import { createWorkflowCtx, parseSchemaInput, schemaInputIssues } from "@alexkroman1/aai/testing";
|
|
25
25
|
import {
|
|
26
26
|
installStubStepFetch,
|
|
27
27
|
installStubGateway as stubGateway,
|
|
@@ -29,7 +29,9 @@ import {
|
|
|
29
29
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
30
30
|
import agentDef, { dailyDigest } from "./agent.ts";
|
|
31
31
|
import {
|
|
32
|
+
dailyDigestFlow,
|
|
32
33
|
formatScheduleInterval,
|
|
34
|
+
POLL_DELAY_MS,
|
|
33
35
|
pollTranscript,
|
|
34
36
|
scheduleIntervalMs,
|
|
35
37
|
submitTranscript,
|
|
@@ -714,3 +716,107 @@ describe("posting the digest", () => {
|
|
|
714
716
|
expect((err as Error).name).not.toBe("FatalError");
|
|
715
717
|
});
|
|
716
718
|
});
|
|
719
|
+
|
|
720
|
+
describe("the body — the run that IS the schedule", () => {
|
|
721
|
+
/**
|
|
722
|
+
* The body driven end to end with no I/O.
|
|
723
|
+
*
|
|
724
|
+
* `runSteps: false` plus a skeleton of results: what this pins is the body's
|
|
725
|
+
* own logic — the digest loop, the shrinking pending set in
|
|
726
|
+
* `waitForTranscripts`, and the sleep between digests — none of which any
|
|
727
|
+
* per-step spec can see, and all of which is the template's actual subject.
|
|
728
|
+
*/
|
|
729
|
+
function driveTwoDigests(pollResults: unknown) {
|
|
730
|
+
const ctx = createWorkflowCtx({
|
|
731
|
+
runSteps: false,
|
|
732
|
+
results: {
|
|
733
|
+
discoverEpisodes: [EPISODE],
|
|
734
|
+
submitTranscript: { id: EPISODE.id, transcriptId: "t_1" },
|
|
735
|
+
pollTranscript: pollResults,
|
|
736
|
+
summarizeTranscript: EPISODE,
|
|
737
|
+
postDigest: { ok: true },
|
|
738
|
+
timestamp: "2026-08-21T00:00:00.000Z",
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
return { ctx };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
test("sends one digest per interval and sleeps BETWEEN them, never after the last", async () => {
|
|
745
|
+
// A run that has delivered everything it owes should end, not sleep for a
|
|
746
|
+
// day and then end.
|
|
747
|
+
const { ctx } = driveTwoDigests({
|
|
748
|
+
id: EPISODE.id,
|
|
749
|
+
transcriptStatus: "completed",
|
|
750
|
+
transcript: "words",
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
const output = await dailyDigestFlow(
|
|
754
|
+
{
|
|
755
|
+
...VALID,
|
|
756
|
+
slackWorkflowTextParam: "text",
|
|
757
|
+
daysToRun: 2,
|
|
758
|
+
maxEpisodesPerDigest: 1,
|
|
759
|
+
intervalEvery: 2,
|
|
760
|
+
intervalUnit: "hours",
|
|
761
|
+
},
|
|
762
|
+
ctx,
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
expect(output.digestsSent).toBe(2);
|
|
766
|
+
expect(output.digestsScheduled).toBe(2);
|
|
767
|
+
// Two digests, ONE sleep.
|
|
768
|
+
expect(ctx.slept).toHaveLength(1);
|
|
769
|
+
expect(ctx.slept[0]?.until).toBe(scheduleIntervalMs(2, "hours"));
|
|
770
|
+
expect(ctx.steps.filter((step) => step.name === "postDigest")).toHaveLength(2);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("reports the last digest it actually sent", async () => {
|
|
774
|
+
const { ctx } = driveTwoDigests({
|
|
775
|
+
id: EPISODE.id,
|
|
776
|
+
transcriptStatus: "completed",
|
|
777
|
+
transcript: "words",
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
const output = await dailyDigestFlow(
|
|
781
|
+
{
|
|
782
|
+
...VALID,
|
|
783
|
+
slackWorkflowTextParam: "text",
|
|
784
|
+
daysToRun: 1,
|
|
785
|
+
maxEpisodesPerDigest: 1,
|
|
786
|
+
intervalEvery: 1,
|
|
787
|
+
intervalUnit: "days",
|
|
788
|
+
},
|
|
789
|
+
ctx,
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
expect(output.lastDigest).toMatchObject({
|
|
793
|
+
sentAt: "2026-08-21T00:00:00.000Z",
|
|
794
|
+
episodes: [EPISODE],
|
|
795
|
+
});
|
|
796
|
+
expect(ctx.slept).toEqual([]);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("gives up on an episode that never finishes rather than failing the digest", async () => {
|
|
800
|
+
// Running out of poll rounds is NOT an error: a partial digest beats none,
|
|
801
|
+
// and the reason is printed where a reader will see it. The poll answers
|
|
802
|
+
// `submitted` forever, so the loop exhausts its rounds.
|
|
803
|
+
const { ctx } = driveTwoDigests({ id: EPISODE.id, transcriptStatus: "submitted" });
|
|
804
|
+
|
|
805
|
+
const output = await dailyDigestFlow(
|
|
806
|
+
{
|
|
807
|
+
...VALID,
|
|
808
|
+
slackWorkflowTextParam: "text",
|
|
809
|
+
daysToRun: 1,
|
|
810
|
+
maxEpisodesPerDigest: 1,
|
|
811
|
+
intervalEvery: 1,
|
|
812
|
+
intervalUnit: "days",
|
|
813
|
+
},
|
|
814
|
+
ctx,
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
expect(output.digestsSent).toBe(1);
|
|
818
|
+
// Every round slept except the last, which is what bounds the wait.
|
|
819
|
+
expect(ctx.slept.length).toBeGreaterThan(0);
|
|
820
|
+
for (const sleep of ctx.slept) expect(sleep.until).toBe(POLL_DELAY_MS);
|
|
821
|
+
});
|
|
822
|
+
});
|
|
@@ -5,9 +5,46 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Mounted with `page()` rather than `client()`: there is no session to build, so
|
|
7
7
|
* no socket, no audio graph, no microphone request. `useWorkflowSubmit()` starts
|
|
8
|
-
* the run, follows its STATUS, and hands back the controls bound to it.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* the run, follows its STATUS, and hands back the controls bound to it.
|
|
9
|
+
*
|
|
10
|
+
* ## The `runId` is durable; the PAGE holding it was not
|
|
11
|
+
*
|
|
12
|
+
* This doc used to say the run id "survives a reload, another device, or
|
|
13
|
+
* `curl`", and every word of that is true of the id and none of it was true of
|
|
14
|
+
* this page: the id lived in React state, so a refresh dropped it while a
|
|
15
|
+
* schedule that may run for a month carried on posting. That is the worst case
|
|
16
|
+
* in `templates/` for losing a handle — the other workflow apps lose a wait of
|
|
17
|
+
* minutes, this one loses the only Stop button a thirty-digest commitment has,
|
|
18
|
+
* and the run is invisible from then on to everything but `curl`.
|
|
19
|
+
*
|
|
20
|
+
* `key` is the handle that survives and `recover: true` is what reads it back,
|
|
21
|
+
* so a later load lands on the same count, the same newest line, and the same
|
|
22
|
+
* Wake and Cancel buttons bound to the same run.
|
|
23
|
+
*
|
|
24
|
+
* **The key is `useRunKey({ storage: "local" })`, and this is the one workflow
|
|
25
|
+
* app that passes that option.** Its siblings take the default,
|
|
26
|
+
* `sessionStorage`, which dies with the tab and covers exactly the interruption
|
|
27
|
+
* they have — a reload, a same-tab navigation, a crashed tab. A schedule
|
|
28
|
+
* outlives all of that by design: closing the browser on Tuesday and coming
|
|
29
|
+
* back on Friday to stop it is the ordinary case here, not an edge one, and a
|
|
30
|
+
* tab-scoped key would answer that with an empty form beside a run still
|
|
31
|
+
* posting to somebody's Slack. So the handle is scoped to the BROWSER, which is
|
|
32
|
+
* as far as it can go without a login (`find` has no per-user filtering; the
|
|
33
|
+
* key is the whole scoping mechanism) — and no further:
|
|
34
|
+
*
|
|
35
|
+
* - **Not the page's own URL.** A `?key=` parameter is pasted into chats,
|
|
36
|
+
* copied into referrers and kept in history, and what a leaked one buys here
|
|
37
|
+
* is not just reading the digest: it is `cancel()` on somebody's schedule,
|
|
38
|
+
* and a completed run's output NAMES the delivery target it has been posting
|
|
39
|
+
* to.
|
|
40
|
+
* - **Not derived from the feeds or the webhook.** Two people watching the same
|
|
41
|
+
* show would recover each other's schedules, and a key derived from a webhook
|
|
42
|
+
* URL would carry a credential into a lookup token — which is why the
|
|
43
|
+
* platform stopped writing keys to the operator's log.
|
|
44
|
+
*
|
|
45
|
+
* A real app with accounts passes the account's own id instead, and then the
|
|
46
|
+
* schedule follows the person to a new device — a promise only a login can
|
|
47
|
+
* keep.
|
|
11
48
|
*
|
|
12
49
|
* ## The form is DECLARED, not written
|
|
13
50
|
*
|
|
@@ -51,6 +88,7 @@ import {
|
|
|
51
88
|
Form,
|
|
52
89
|
page,
|
|
53
90
|
SubmitButton,
|
|
91
|
+
useRunKey,
|
|
54
92
|
useWorkflowSubmit,
|
|
55
93
|
WorkflowFields,
|
|
56
94
|
WorkflowProgress,
|
|
@@ -59,18 +97,54 @@ import "@alexkroman1/aai-ui/styles.css";
|
|
|
59
97
|
// ERASED at build time, so naming the agent's own type costs the browser bundle
|
|
60
98
|
// nothing — and it is what stops this file restating a shape `workflows/
|
|
61
99
|
// digest.ts` already declares.
|
|
100
|
+
import { useState } from "react";
|
|
62
101
|
import type { dailyDigest } from "./agent.ts";
|
|
63
102
|
|
|
64
103
|
/** The workflow this page drives. Matches the key in `workflowApp({ workflows })`. */
|
|
65
104
|
const WORKFLOW = "dailyDigest";
|
|
66
105
|
|
|
106
|
+
/**
|
|
107
|
+
* What the page says while a schedule is live — three situations, one line
|
|
108
|
+
* each, and none of them the sentence this page used to print.
|
|
109
|
+
*
|
|
110
|
+
* That one was "You can close this tab — the run continues without it": true
|
|
111
|
+
* about the run and false about the page, which is the worst shape a
|
|
112
|
+
* reassurance can have. The run did continue, for up to a month, and nothing
|
|
113
|
+
* could name it again. Now the promise can be stronger AND narrower — this
|
|
114
|
+
* browser, not any tab anywhere — and the load that did not press the button
|
|
115
|
+
* gets its own words, because a schedule appearing in front of somebody is owed
|
|
116
|
+
* an explanation.
|
|
117
|
+
*/
|
|
118
|
+
function pendingNote(startedHere: boolean, found: boolean): string {
|
|
119
|
+
if (startedHere)
|
|
120
|
+
return "You can close this tab — the digest keeps posting, and this browser will find it again.";
|
|
121
|
+
if (!found) return "Looking for a schedule this browser started earlier…";
|
|
122
|
+
return "This is a schedule this browser started earlier. It is still posting.";
|
|
123
|
+
}
|
|
124
|
+
|
|
67
125
|
export function App() {
|
|
126
|
+
// This BROWSER's handle on its schedules — minted once and kept for as long
|
|
127
|
+
// as storage lives for this origin, which is the option this template exists
|
|
128
|
+
// to argue for. See the module doc.
|
|
129
|
+
const key = useRunKey({ storage: "local" });
|
|
130
|
+
// Did THIS load start the schedule? A later load cannot have, and that is the
|
|
131
|
+
// only way the page can tell "scheduled just now" from "still running from
|
|
132
|
+
// Tuesday" — the hook reports the run, not who asked for it.
|
|
133
|
+
const [startedHere, setStartedHere] = useState(false);
|
|
68
134
|
// The generic is what makes `run.status === "completed"` narrow to a TYPED
|
|
69
135
|
// `run.output` instead of `unknown`. `error` is the agent's own sentence for a
|
|
70
136
|
// rejected input — better copy than anything this page could write, and the
|
|
71
137
|
// reason there is no `try`/`catch` here.
|
|
72
|
-
const { submitForm, run, pending, error, wake, cancel } =
|
|
73
|
-
|
|
138
|
+
const { submitForm, run, pending, error, wake, cancel } = useWorkflowSubmit<typeof dailyDigest>(
|
|
139
|
+
WORKFLOW,
|
|
140
|
+
{
|
|
141
|
+
// Recorded with the run, and read back on the next load. Neither half is
|
|
142
|
+
// useful alone: without the key there is nothing to find the run by, and
|
|
143
|
+
// without `recover` the key is only ever written.
|
|
144
|
+
key,
|
|
145
|
+
recover: true,
|
|
146
|
+
},
|
|
147
|
+
);
|
|
74
148
|
|
|
75
149
|
return (
|
|
76
150
|
<main className="mx-auto flex max-w-2xl flex-col gap-6 p-8">
|
|
@@ -78,7 +152,13 @@ export function App() {
|
|
|
78
152
|
|
|
79
153
|
{/* `submit()` resolves as soon as the run EXISTS — deliberately not when it
|
|
80
154
|
finishes, which here could be a month away. */}
|
|
81
|
-
<Form
|
|
155
|
+
<Form
|
|
156
|
+
onSubmit={(values) => {
|
|
157
|
+
setStartedHere(true);
|
|
158
|
+
return submitForm(values);
|
|
159
|
+
}}
|
|
160
|
+
error={error}
|
|
161
|
+
>
|
|
82
162
|
<WorkflowFields workflow={WORKFLOW} />
|
|
83
163
|
<SubmitButton pending={pending}>
|
|
84
164
|
{pending ? "Digest scheduled" : "Start digest"}
|
|
@@ -87,8 +167,9 @@ export function App() {
|
|
|
87
167
|
|
|
88
168
|
{/* A run that has not settled says so. `pending` is not derivable from the
|
|
89
169
|
snapshot alone — an id the agent never knew leaves `run` undefined,
|
|
90
|
-
which would otherwise read as "still waiting" forever
|
|
91
|
-
|
|
170
|
+
which would otherwise read as "still waiting" forever, and on a later
|
|
171
|
+
load it is also true while the schedule is being looked up by key. */}
|
|
172
|
+
{pending && <p>{pendingNote(startedHere, run !== undefined)}</p>}
|
|
92
173
|
|
|
93
174
|
{/* The run's own narration, newest line only. `lines={1}` is the window;
|
|
94
175
|
everything else — the replay, and the "serves no stream" case that is
|