@alexkroman1/aai-cli 10.0.0 → 11.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-B31MqYaD.mjs → _bundler-C31rlcYz.mjs} +1 -1
- package/dist/{_dev-server-Bo5dSBzQ.mjs → _dev-server-DWvkNoIS.mjs} +3 -3
- package/dist/{_vite-env-BNveawd1.mjs → _vite-env-D9Qj6KPX.mjs} +4 -2
- package/dist/_workflow-determinism-f9J-5Zlj.mjs +206 -0
- package/dist/_workflow-determinism.d.ts +118 -0
- package/dist/{build-DKC2e9o_.mjs → build-BLlLOrng.mjs} +4 -2
- package/dist/cli.mjs +372 -353
- package/dist/{client-bundler-CWnG42cU.mjs → client-bundler-j5c46x0G.mjs} +3 -2
- package/dist/client-bundler.mjs +1 -1
- package/dist/{deploy-CchW5pmw.mjs → deploy-BzZ-r4iM.mjs} +6 -4
- package/dist/{dev-BfVl1uBA.mjs → dev-BDykFvcc.mjs} +1 -1
- package/dist/{eval-DwNwdqmS.mjs → eval-0qEOdm0l.mjs} +1 -1
- package/dist/{init-BRfoc2EK.mjs → init-BtJI306C.mjs} +1 -1
- package/dist/{login-BBuM1sxH.mjs → login-DeUETobb.mjs} +4 -1
- package/dist/scaffold/CLAUDE.md +190 -11
- package/dist/scaffold/package.json +4 -4
- package/dist/{studio-wVWNLREn.mjs → studio-BxAS_FQQ.mjs} +4 -1
- package/dist/templates/call-audit/agent.test.ts +105 -26
- package/dist/templates/call-audit/workflows/audit.ts +9 -26
- package/dist/templates/dispatch-center/client.tsx +36 -2
- package/dist/templates/infocom-adventure/client.tsx +37 -9
- package/dist/templates/link-digest/agent.eval.test.ts +1 -1
- package/dist/templates/link-digest/agent.test.ts +164 -8
- package/dist/templates/link-digest/workflows/digest.ts +63 -20
- package/dist/templates/podcast-digest/agent.eval.test.ts +7 -4
- package/dist/templates/podcast-digest/agent.test.ts +265 -24
- package/dist/templates/podcast-digest/workflows/digest.ts +2 -2
- package/dist/templates/podcast-digest/workflows/feeds.ts +78 -73
- package/dist/templates/recap-workflow/agent.test.ts +206 -20
- package/dist/templates/recap-workflow/workflows/recap.ts +17 -14
- package/dist/templates/redline/agent.test.ts +137 -11
- package/dist/templates/research-workflow/agent.eval.test.ts +1 -1
- package/dist/templates/research-workflow/agent.test.ts +131 -9
- package/dist/templates/research-workflow/workflows/research.ts +1 -1
- package/dist/templates/retail/client.tsx +30 -2
- package/dist/templates/spoken-summary/agent.eval.test.ts +4 -1
- package/dist/templates/spoken-summary/agent.test.ts +117 -8
- package/dist/templates/spoken-summary/workflows/summarize.ts +1 -1
- package/dist/templates/transcription-workflow/agent.test.ts +127 -13
- package/dist/templates/transcription-workflow/workflows/batch.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -3
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +4 -23
- package/dist/{test-CvwgeVSQ.mjs → test-CiLab-AA.mjs} +2 -1
- package/dist/worker-bundler.mjs +1 -1
- package/package.json +25 -24
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { o as errorMessage$1, s as fileExists } from "./_utils-B8QmtFhK.mjs";
|
|
3
|
-
import { n as withPreservedNodeEnv, t as DEDUPED_PEERS } from "./_vite-env-
|
|
3
|
+
import { n as withPreservedNodeEnv, t as DEDUPED_PEERS } from "./_vite-env-D9Qj6KPX.mjs";
|
|
4
4
|
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { omitUndefined } from "@alexkroman1/aai/utils";
|
|
@@ -82,7 +82,8 @@ const DEFAULT_OUT_DIR = ".aai/client";
|
|
|
82
82
|
* API and not covered by semver.
|
|
83
83
|
*/
|
|
84
84
|
async function buildClient(cwd, opts = {}) {
|
|
85
|
-
|
|
85
|
+
const clientEntry = path.join(cwd, "client.tsx");
|
|
86
|
+
if (!await fileExists(clientEntry)) return {};
|
|
86
87
|
const outDir = opts.outDir ?? DEFAULT_OUT_DIR;
|
|
87
88
|
const clientDir = path.join(cwd, outDir);
|
|
88
89
|
let cleanupHtml = () => {};
|
package/dist/client-bundler.mjs
CHANGED
|
@@ -3,10 +3,11 @@ import { a as ok } from "./_output-CKkmWs7i.mjs";
|
|
|
3
3
|
import { n as log, r as notify, t as fmtUrl } from "./_ui-u7T4YooX.mjs";
|
|
4
4
|
import { o as errorMessage$1 } from "./_utils-B8QmtFhK.mjs";
|
|
5
5
|
import { s as updateProjectConfig } from "./_config-CmJOFsAP.mjs";
|
|
6
|
-
import { i as evalWorkerConfig, t as buildAgentBundle } from "./_bundler-
|
|
6
|
+
import { i as evalWorkerConfig, t as buildAgentBundle } from "./_bundler-C31rlcYz.mjs";
|
|
7
7
|
import { t as resolveServerEnv } from "./_server-common-6e0QI4mq.mjs";
|
|
8
8
|
import { a as resolveDeployTarget } from "./_agent-BTwZJu4P.mjs";
|
|
9
9
|
import { assertTypechecks } from "./_typecheck-gate-BXvUNnfs.mjs";
|
|
10
|
+
import { n as scanWorkflowDeterminism, t as determinismWarnings } from "./_workflow-determinism-f9J-5Zlj.mjs";
|
|
10
11
|
import { d as apiTestSeam, f as checkedResponse, i as projectNameFromDir, u as apiRequest } from "./_studio-B1waXMuR.mjs";
|
|
11
12
|
import { isRecord, omitUndefined } from "@alexkroman1/aai/utils";
|
|
12
13
|
import { gzipSync } from "node:zlib";
|
|
@@ -19,7 +20,7 @@ async function runDeploy(opts) {
|
|
|
19
20
|
worker: opts.bundle.worker,
|
|
20
21
|
clientFiles: opts.bundle.clientFiles
|
|
21
22
|
}));
|
|
22
|
-
|
|
23
|
+
const data = await apiRequest(`${opts.url}/deploy`, {
|
|
23
24
|
method: "POST",
|
|
24
25
|
body,
|
|
25
26
|
headers: {
|
|
@@ -31,7 +32,8 @@ async function runDeploy(opts) {
|
|
|
31
32
|
hints: { 413: "Your bundle is too large. Try reducing dependencies or splitting your agent." },
|
|
32
33
|
...opts.slug ? {} : { retry: 0 },
|
|
33
34
|
...apiTestSeam(opts)
|
|
34
|
-
})
|
|
35
|
+
});
|
|
36
|
+
return checkedResponse(data, (value) => isRecord(value) && typeof value.slug === "string", `the deploy route at ${opts.url}`);
|
|
35
37
|
}
|
|
36
38
|
//#endregion
|
|
37
39
|
//#region deploy.ts
|
|
@@ -49,7 +51,7 @@ async function executeDeploy(opts) {
|
|
|
49
51
|
const { missingCredentialMessage, missingCredentials } = await preflightModule;
|
|
50
52
|
const config = await evalWorkerConfig(bundle.worker);
|
|
51
53
|
const missing = config ? missingCredentials(config, uploadEnv) : [];
|
|
52
|
-
const warnings = missing.length > 0 ? [missingCredentialMessage(missing)] : [];
|
|
54
|
+
const warnings = [...missing.length > 0 ? [missingCredentialMessage(missing)] : [], ...determinismWarnings(await scanWorkflowDeterminism(cwd))];
|
|
53
55
|
for (const warning of warnings) notify("warn", warning);
|
|
54
56
|
log.step(`Deploying${slug ? ` ${slug}` : ""}…`);
|
|
55
57
|
const deployed = await runDeploy({
|
|
@@ -12,7 +12,7 @@ import { styleText } from "node:util";
|
|
|
12
12
|
async function executeDev(opts) {
|
|
13
13
|
const port = parsePort(opts.port);
|
|
14
14
|
const agentName = path.basename(path.resolve(opts.cwd));
|
|
15
|
-
const { startDevServer } = await import("./_dev-server-
|
|
15
|
+
const { startDevServer } = await import("./_dev-server-DWvkNoIS.mjs");
|
|
16
16
|
let cleanup;
|
|
17
17
|
let shuttingDown = false;
|
|
18
18
|
const onSignal = () => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { a as ok, n as fail } from "./_output-CKkmWs7i.mjs";
|
|
3
3
|
import { n as log } from "./_ui-u7T4YooX.mjs";
|
|
4
4
|
import { t as resolveServerEnv } from "./_server-common-6e0QI4mq.mjs";
|
|
5
|
-
import { classifyVitestError, runVitest } from "./test-
|
|
5
|
+
import { classifyVitestError, runVitest } from "./test-CiLab-AA.mjs";
|
|
6
6
|
//#region eval.ts
|
|
7
7
|
/**
|
|
8
8
|
* `aai eval` — run the agent's behaviour evals via vitest.
|
|
@@ -106,7 +106,7 @@ function collectWarnings() {
|
|
|
106
106
|
}
|
|
107
107
|
/** Publish after init and return deploy metadata if successful. */
|
|
108
108
|
async function tryPublish(cwd, server, warn) {
|
|
109
|
-
const { executePublish } = await import("./studio-
|
|
109
|
+
const { executePublish } = await import("./studio-BxAS_FQQ.mjs");
|
|
110
110
|
try {
|
|
111
111
|
const result = await executePublish({
|
|
112
112
|
cwd,
|
|
@@ -32,7 +32,10 @@ const LINK_POLL_INTERVAL_MS = 2e3;
|
|
|
32
32
|
const LINK_TIMEOUT_MS = 3e5;
|
|
33
33
|
async function jsonBody(res, what) {
|
|
34
34
|
const body = await res.json().catch(() => null);
|
|
35
|
-
if (!res.ok)
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const detail = body?.error ?? body?.msg ?? `HTTP ${res.status}`;
|
|
37
|
+
throw new CliError("login_failed", `${what} failed: ${detail}`);
|
|
38
|
+
}
|
|
36
39
|
if (body === null) throw new CliError("login_failed", `${what} returned an invalid response`);
|
|
37
40
|
return body;
|
|
38
41
|
}
|
package/dist/scaffold/CLAUDE.md
CHANGED
|
@@ -428,7 +428,10 @@ export async function digestFlow(input: { url: string }, ctx: WorkflowCtx) {
|
|
|
428
428
|
|
|
429
429
|
// Suspended, not blocked: the container is free to exit here and the run
|
|
430
430
|
// resumes when it comes due. Six hours works the same as ten seconds.
|
|
431
|
-
|
|
431
|
+
//
|
|
432
|
+
// The first argument NAMES the wait, exactly as a step's name does, and for
|
|
433
|
+
// the same reason: it is the wait's identity in the journal.
|
|
434
|
+
await ctx.sleep("settle", 10_000);
|
|
432
435
|
|
|
433
436
|
const filedAt = await ctx.step("file", () => file(digest));
|
|
434
437
|
return { ...digest, filedAt };
|
|
@@ -455,8 +458,10 @@ them:
|
|
|
455
458
|
|
|
456
459
|
- **The body replays from the top on every resume**, so it holds no live handle
|
|
457
460
|
and makes no undurable decision — no `Date.now()`, no `Math.random()`, no
|
|
458
|
-
`crypto.randomUUID()`, no `fetch`.
|
|
459
|
-
|
|
461
|
+
`crypto.randomUUID()`, no `fetch`. The three commonest have methods of their
|
|
462
|
+
own (`ctx.now()`, `ctx.random()`, `ctx.uuid()` — see below); anything else goes
|
|
463
|
+
inside a `ctx.step`, whose result is journaled and returned unchanged on
|
|
464
|
+
replay.
|
|
460
465
|
- **A step's arguments and return value cross a queue**, so they must be
|
|
461
466
|
JSON-shaped and small. Put bytes in storage and pass the key.
|
|
462
467
|
- **A step gets no tool context.** There is no `ctx.db` and no `ctx.generate`
|
|
@@ -481,6 +486,66 @@ const digest = await ctx.step("summarize", () => summarize(input.url), {
|
|
|
481
486
|
});
|
|
482
487
|
```
|
|
483
488
|
|
|
489
|
+
**And a step body can read which attempt it is on**, so a step may degrade rather
|
|
490
|
+
than fail — a smaller model on the last try beats a failed run:
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
import { stepInfo } from "@alexkroman1/aai/step";
|
|
494
|
+
|
|
495
|
+
declare function callModel(url: string, model: string): Promise<string>;
|
|
496
|
+
|
|
497
|
+
export async function summarize(url: string) {
|
|
498
|
+
const step = stepInfo();
|
|
499
|
+
// `undefined` outside a run — a spec calling this directly — which reads as
|
|
500
|
+
// "not retrying", the same branch a first attempt takes.
|
|
501
|
+
const model = step?.isLastAttempt === true ? "small" : "large";
|
|
502
|
+
return await callModel(url, model);
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Read `isLastAttempt` rather than comparing `attempt` against a number you have
|
|
507
|
+
written down: the ceiling lives at the `ctx.step` call site, and a body that
|
|
508
|
+
restates it degrades early on every run once the two disagree — silently, since
|
|
509
|
+
it still returns an answer. `stubStepInfo` from `@alexkroman1/aai/testing` is how
|
|
510
|
+
a test reaches the retry branch.
|
|
511
|
+
|
|
512
|
+
### A clock, a random number and a uuid: `ctx.now`, `ctx.random`, `ctx.uuid`
|
|
513
|
+
|
|
514
|
+
The three undurable reads a body most often wants, each journaled — read once at
|
|
515
|
+
the first reach, and the same value on every later walk:
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
import type { WorkflowCtx } from "@alexkroman1/aai";
|
|
519
|
+
|
|
520
|
+
declare function charge(amount: number, idempotencyKey: string, jitter: number): Promise<void>;
|
|
521
|
+
|
|
522
|
+
export async function chargeFlow(input: { amount: number }, ctx: WorkflowCtx) {
|
|
523
|
+
const startedAt = await ctx.now(); // epoch ms, decided once
|
|
524
|
+
const idempotencyKey = await ctx.uuid(); // still the same id after a crash
|
|
525
|
+
const jitter = await ctx.random(); // one float in [0, 1), journaled per call
|
|
526
|
+
|
|
527
|
+
await ctx.step("charge", () => charge(input.amount, idempotencyKey, jitter));
|
|
528
|
+
return { elapsedMs: (await ctx.now()) - startedAt };
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
`ctx.uuid()` is what an idempotency key for a downstream API wants: minted once,
|
|
533
|
+
and the same value after a resume, so a retried request is recognisably the same
|
|
534
|
+
request rather than a second one. `ctx.random()` draws one float per CALL, so a
|
|
535
|
+
loop is correct as written; a BULK draw belongs in a step
|
|
536
|
+
(`ctx.step("jitter", () => Array.from({ length: 1000 }, Math.random))`), which is
|
|
537
|
+
one journal entry instead of a thousand.
|
|
538
|
+
|
|
539
|
+
Two rules:
|
|
540
|
+
|
|
541
|
+
- **Call them from the BODY, never inside a `ctx.step`** — the engine refuses one
|
|
542
|
+
there and the message names the fix. Inside a step there is nothing to fix: a
|
|
543
|
+
step's internals are not replayed, only its result, so a plain `Date.now()` in
|
|
544
|
+
a step body is already durable and is what to write.
|
|
545
|
+
- **A `ctx.uuid()` is not a hook TOKEN.** `ctx.waitFor`'s token has to be
|
|
546
|
+
DERIVED from the run's own input, because whoever signals is usually a tool and
|
|
547
|
+
a tool cannot see the body's local variables. See below.
|
|
548
|
+
|
|
484
549
|
### Waiting: `ctx.sleep` and `ctx.waitFor`
|
|
485
550
|
|
|
486
551
|
Both SUSPEND the run — the body stops, the container is free, and the engine
|
|
@@ -493,8 +558,8 @@ process. Under `aai dev` without a `DATABASE_URL` the store is memory, so a wait
|
|
|
493
558
|
lives only as long as the dev server. The boot line reports which one is in play.
|
|
494
559
|
|
|
495
560
|
```ts no-check
|
|
496
|
-
// A duration in milliseconds
|
|
497
|
-
await ctx.sleep(6 * 60 * 60 * 1000, { correlationId: "review
|
|
561
|
+
// A label, then a duration in milliseconds or an absolute Date.
|
|
562
|
+
await ctx.sleep("review-window", 6 * 60 * 60 * 1000, { correlationId: "review" });
|
|
498
563
|
|
|
499
564
|
// Until somebody outside the run answers, via `ctx.workflows.signal(token, …)`
|
|
500
565
|
// from a tool, or by a delivery to `publicWebhookUrl` — both hops reach the
|
|
@@ -505,8 +570,15 @@ const approval = await ctx.waitFor<{ approved: boolean }>(approvalToken(input.id
|
|
|
505
570
|
if (approval === undefined) return { published: false, reason: "nobody approved" };
|
|
506
571
|
```
|
|
507
572
|
|
|
508
|
-
|
|
573
|
+
Five things worth knowing:
|
|
509
574
|
|
|
575
|
+
- **A wait's NAME is its identity, exactly like a step's.** A sleep's `label` and
|
|
576
|
+
a `waitFor`'s token are what the journal keys the wait on
|
|
577
|
+
(`sleep!<label>#<occurrence>`, `hook!<token>#<occurrence>`), so make a label a
|
|
578
|
+
string literal, give two call sites two labels, and let a loop reuse one — the
|
|
579
|
+
occurrence count separates the iterations. This is what makes a wait behind an
|
|
580
|
+
`if` safe: the body can reach a different NUMBER of waits on two walks and each
|
|
581
|
+
one still finds its own record.
|
|
510
582
|
- **A hook's token must be DERIVED, not random.** Whoever signals is usually a
|
|
511
583
|
tool, and a tool cannot see the body's local variables — so export one function
|
|
512
584
|
that computes the token from the run's own input and import it in both places.
|
|
@@ -516,10 +588,15 @@ Four things worth knowing:
|
|
|
516
588
|
- **`timeoutMs` resolves `undefined` when the window closes unanswered.** A
|
|
517
589
|
closing window is an outcome to branch on, not a failure, and the engine closes
|
|
518
590
|
the hook as it shuts so a late answer cannot change what already happened.
|
|
519
|
-
- **
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
591
|
+
- **Racing the two WORKS, and is still not how to put a deadline on a wait.** A
|
|
592
|
+
wait no longer unwinds the stack — it hands back a promise that never settles
|
|
593
|
+
— so the body reaches every wait a `race` or an `all` puts in front of it and
|
|
594
|
+
the run suspends once, on the earliest deadline among them. Reach for a race
|
|
595
|
+
when the two waits are genuinely independent (a review window beside a retry
|
|
596
|
+
backoff). For a deadline ON a wait, use `timeoutMs`: it is journaled WITH the
|
|
597
|
+
hook, so one decision fixes the window, and its timeout arm CLOSES the hook
|
|
598
|
+
before the body continues — a race has no such moment, and a signal landing
|
|
599
|
+
just after it would make the next replay answer a window this one timed out.
|
|
523
600
|
- **`ctx.workflows.wakeUp(runId, { correlationIds: [id] })`** ends a sleep early,
|
|
524
601
|
which is how a "send it now" tool cuts a scheduled wait short. Naming no ids
|
|
525
602
|
wakes every outstanding SLEEP and deliberately not a `waitFor` deadline, so
|
|
@@ -574,6 +651,75 @@ trap somebody has already paid for:
|
|
|
574
651
|
pnpm dev` is what makes it reachable. Until you set it, treat local runs as
|
|
575
652
|
coverage of the backstop only.
|
|
576
653
|
|
|
654
|
+
### Testing a workflow body
|
|
655
|
+
|
|
656
|
+
Steps are ordinary exported functions, so a spec imports and calls them. The
|
|
657
|
+
BODY needs an engine, and there are two, for two different questions.
|
|
658
|
+
|
|
659
|
+
**"What did the body ask for?"** — `createWorkflowCtx` from
|
|
660
|
+
`@alexkroman1/aai/testing`. It runs the steps and records the names, the retry
|
|
661
|
+
policies and the sleeps, over one walk with no journal. Nothing replays, so a
|
|
662
|
+
spec built on it must not claim to test durability.
|
|
663
|
+
|
|
664
|
+
```ts no-check
|
|
665
|
+
import { createWorkflowCtx } from "@alexkroman1/aai/testing";
|
|
666
|
+
|
|
667
|
+
const ctx = createWorkflowCtx({ runSteps: false });
|
|
668
|
+
await digestFlow({ url: "https://example.com/a" }, ctx);
|
|
669
|
+
|
|
670
|
+
expect(ctx.steps.map((s) => s.name)).toEqual(["fetchArticle", "summarize", "file"]);
|
|
671
|
+
expect(ctx.steps.find((s) => s.name === "summarize")?.maxAttempts).toBe(6);
|
|
672
|
+
expect(ctx.slept).toEqual([{ until: 10_000 }]);
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
**"Is the run actually durable?"** — `runWorkflow` from
|
|
676
|
+
`@alexkroman1/aai-runtime/testing`. It starts the declared workflow on the real
|
|
677
|
+
replay engine over an in-memory journal, one delivery at a time, with a
|
|
678
|
+
suspension RECORDED rather than waited out. So a body that sleeps six hours
|
|
679
|
+
costs a spec nothing, and the run really suspends, really resumes off its
|
|
680
|
+
journal, and really survives a restart.
|
|
681
|
+
|
|
682
|
+
```ts no-check
|
|
683
|
+
import { runWorkflow } from "@alexkroman1/aai-runtime/testing";
|
|
684
|
+
import { digest } from "./agent.ts";
|
|
685
|
+
|
|
686
|
+
// Parks on the wait instead of blocking, with the work before it journaled.
|
|
687
|
+
const run = await runWorkflow(digest, { url: "https://example.com/a" }, {
|
|
688
|
+
name: "digest",
|
|
689
|
+
});
|
|
690
|
+
expect(run.status).toBe("running");
|
|
691
|
+
expect(run.wakeAt).toBeGreaterThan(Date.now());
|
|
692
|
+
expect(run.steps.map((s) => s.name)).toEqual(["fetchArticle", "summarize"]);
|
|
693
|
+
|
|
694
|
+
// Ends the wait the way `ctx.workflows.wakeUp` does, and the body continues.
|
|
695
|
+
await run.advanceSleep();
|
|
696
|
+
expect(run.status).toBe("completed");
|
|
697
|
+
expect(run.deliveries).toBe(2);
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
Three more things it can do, each the thing a durable body is written for:
|
|
701
|
+
|
|
702
|
+
- `run.signal(token, payload)` answers a `ctx.waitFor`, so an approval gate is
|
|
703
|
+
testable without a second process.
|
|
704
|
+
- `{ crashAt: "summarize" }` kills the first delivery that reaches that step,
|
|
705
|
+
before its body runs — a worker that died mid-run. `await run.restart()` then
|
|
706
|
+
boots a fresh engine over the same journal, and only the step that never
|
|
707
|
+
settled runs again.
|
|
708
|
+
- `{ journal }` shares one store between runs, so a spec can assert what a
|
|
709
|
+
second run sees.
|
|
710
|
+
|
|
711
|
+
Stub the steps' collaborators at the seams they really use — a step's HTTP goes
|
|
712
|
+
through the published `stepFetch` slot, so a model call and a page fetch are BOTH
|
|
713
|
+
answered there. `stubGatewayRoute` composes the two:
|
|
714
|
+
|
|
715
|
+
```ts no-check
|
|
716
|
+
import { stubGatewayRoute } from "@alexkroman1/aai/testing";
|
|
717
|
+
import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
|
|
718
|
+
|
|
719
|
+
const model = stubGatewayRoute('{"headline":"H","points":["a"]}');
|
|
720
|
+
installStubStepFetch((request) => model.route(request) ?? { body: PAGE_HTML });
|
|
721
|
+
```
|
|
722
|
+
|
|
577
723
|
### A step's env, and calling a model from one
|
|
578
724
|
|
|
579
725
|
A step has no `ctx`, so the two things tool code takes for granted come from
|
|
@@ -1712,7 +1858,7 @@ safe from a `workflows/*.ts` module and from a browser bundle:
|
|
|
1712
1858
|
| `formatBytes`, `formatDuration`, `countWords`, `plural` | Narration. Each returns ONE fixed shape, so a step's progress line and the page rendering the same run cannot disagree — they did, one template printing `1:04:09` from its workflow and `64:09` from its page |
|
|
1713
1859
|
| `pushCapped(list, item, max)` | An append that keeps the last N, for a log a session accumulates |
|
|
1714
1860
|
| `isRecord(x)`, `omitUndefined(obj)` | The object guard and the spread-free way to drop undefined fields |
|
|
1715
|
-
| `decodeHtmlEntities(text)` |
|
|
1861
|
+
| `decodeHtmlEntities(text)` | Six entities, no dependency. Enough for a `client.tsx`; for a page or a feed see `/html` below |
|
|
1716
1862
|
| `createKeyedLock()` / `withLock(lock, key, work)` | Serializing async work per key |
|
|
1717
1863
|
|
|
1718
1864
|
**`createKeyedLock` is the one an agent most needs and least expects to.** The
|
|
@@ -1722,6 +1868,39 @@ that case — `slot.update`'s window is synchronous — so reach for the lock wh
|
|
|
1722
1868
|
the thing being mutated is outside the session. `withLock` takes an optional
|
|
1723
1869
|
acquire deadline and throws `KeyedLockTimeoutError` when it runs out.
|
|
1724
1870
|
|
|
1871
|
+
## Reading a page or a feed — `@alexkroman1/aai/html`
|
|
1872
|
+
|
|
1873
|
+
A step that fetches somebody else's markup gets a real parse rather than a
|
|
1874
|
+
regex. Node-only (it pulls two parsers), so import it from `workflows/*.ts` or a
|
|
1875
|
+
tool, never from `client.tsx`:
|
|
1876
|
+
|
|
1877
|
+
```ts
|
|
1878
|
+
import { htmlToText, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
|
|
1879
|
+
|
|
1880
|
+
declare const html: string;
|
|
1881
|
+
declare const xml: string;
|
|
1882
|
+
|
|
1883
|
+
// A page, reduced to the prose worth putting in a prompt. `<script>` and
|
|
1884
|
+
// `<style>` bodies never survive, and `maxChars` caps what crosses the wire.
|
|
1885
|
+
const article = htmlToText(html, { maxChars: 20_000 });
|
|
1886
|
+
|
|
1887
|
+
// `og:title` when the page declares one, else its `<title>` element.
|
|
1888
|
+
const { title, description, feedUrls } = pageMetadata(html);
|
|
1889
|
+
|
|
1890
|
+
// RSS, Atom and RDF alike. `published` is ISO whatever the feed wrote, and
|
|
1891
|
+
// titles come back as TEXT — feeds wrap HTML in CDATA as a matter of course.
|
|
1892
|
+
const feed = parseFeed(xml);
|
|
1893
|
+
const episodes = feed?.items.filter((item) => item.enclosureUrl !== undefined) ?? [];
|
|
1894
|
+
```
|
|
1895
|
+
|
|
1896
|
+
**Reach for this rather than writing the patterns.** Both are cheap to get
|
|
1897
|
+
wrong in ways that only show up on real pages: `<[^>]+>` cuts a tag whose
|
|
1898
|
+
attribute contains a `>`, `<script[^>]*>[\s\S]*?<\/script>` leaves the whole
|
|
1899
|
+
script in your prompt when the page was truncated mid-tag, and
|
|
1900
|
+
`indexOf("<title>")` finds an entry's title rather than a channel's. The
|
|
1901
|
+
`link-digest` and `podcast-digest` templates each shipped a version of those
|
|
1902
|
+
before this subpath existed.
|
|
1903
|
+
|
|
1725
1904
|
## Persisting data — bring your own client
|
|
1726
1905
|
|
|
1727
1906
|
**There is no `ctx.db`.** It was a SQL handle on the tool context, backed first
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"publish:agent": "aai publish"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@alexkroman1/aai": "^
|
|
18
|
-
"@alexkroman1/aai-runtime": "^
|
|
19
|
-
"@alexkroman1/aai-ui": "^
|
|
17
|
+
"@alexkroman1/aai": "^11.0.0",
|
|
18
|
+
"@alexkroman1/aai-runtime": "^11.0.0",
|
|
19
|
+
"@alexkroman1/aai-ui": "^11.0.0",
|
|
20
20
|
"react": "^19.2.8",
|
|
21
21
|
"react-dom": "^19.2.8",
|
|
22
22
|
"tailwindcss": "^4.0.0",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"zod": "^4.4.3"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@alexkroman1/aai-cli": "^
|
|
27
|
+
"@alexkroman1/aai-cli": "^11.0.0",
|
|
28
28
|
"@tailwindcss/vite": "^4.3.3",
|
|
29
29
|
"@types/node": "^26.2.0",
|
|
30
30
|
"@types/react": "^19.2.18",
|
|
@@ -97,7 +97,10 @@ async function pushProject(opts) {
|
|
|
97
97
|
const { files, warnings } = await collectSourceFiles(opts.cwd);
|
|
98
98
|
for (const warning of warnings) log.warn(warning);
|
|
99
99
|
if (Object.keys(files).length === 0) throw new Error("Nothing to push — this directory has no project files.");
|
|
100
|
-
if (!files["agent.ts"] && existsSync(path.join(opts.cwd, "agent.ts")))
|
|
100
|
+
if (!files["agent.ts"] && existsSync(path.join(opts.cwd, "agent.ts"))) {
|
|
101
|
+
const reason = warnings.find((w) => w.startsWith("agent.ts "));
|
|
102
|
+
throw new CliError("entry_not_synced", reason ?? "agent.ts exists locally but was not synced.", "The entry file must sync to deploy — reduce its size or fix its encoding.");
|
|
103
|
+
}
|
|
101
104
|
let project = config?.studioProject;
|
|
102
105
|
let baseHash = config?.studioSourceHash;
|
|
103
106
|
let slug = config?.slug;
|
|
@@ -21,12 +21,18 @@
|
|
|
21
21
|
* argv they will run, and the classification of a failure — which is where a
|
|
22
22
|
* mistake is silent, since a `timeout` called fatal is a run that gives up on work
|
|
23
23
|
* that would have finished.
|
|
24
|
+
*
|
|
25
|
+
* The last block adds a third: the ENGINE's half of that classification, on a
|
|
26
|
+
* real run. `runWorkflow` starts this desk's declared workflow on the replay
|
|
27
|
+
* engine, and with no ffmpeg the first step fails fatally — which is exactly the
|
|
28
|
+
* case worth asserting, because `FatalError` exists to stop the engine spending
|
|
29
|
+
* the six attempts this call site asks for on a file that will never convert.
|
|
24
30
|
*/
|
|
25
31
|
|
|
26
32
|
import { readdir } from "node:fs/promises";
|
|
27
33
|
import { tmpdir } from "node:os";
|
|
28
34
|
import { FatalError, RetryableError } from "@alexkroman1/aai/step-errors";
|
|
29
|
-
import { createWorkflowCtx, stubSpeech } from "@alexkroman1/aai/testing";
|
|
35
|
+
import { createWorkflowCtx, stubSpeech, WORKFLOW_CTX_NOW } from "@alexkroman1/aai/testing";
|
|
30
36
|
import {
|
|
31
37
|
installStubGateway,
|
|
32
38
|
installStubReporter,
|
|
@@ -34,12 +40,12 @@ import {
|
|
|
34
40
|
installStubTranscribe,
|
|
35
41
|
installStubUploads,
|
|
36
42
|
} from "@alexkroman1/aai/testing/vitest";
|
|
43
|
+
import { runWorkflow } from "@alexkroman1/aai-runtime/testing";
|
|
37
44
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
38
45
|
import agentDef, { audit } from "./agent.ts";
|
|
39
46
|
import {
|
|
40
47
|
auditFlow,
|
|
41
48
|
joinSegments,
|
|
42
|
-
now,
|
|
43
49
|
SEGMENT_CONCURRENCY,
|
|
44
50
|
transcribeSegment,
|
|
45
51
|
} from "./workflows/audit.ts";
|
|
@@ -732,20 +738,6 @@ describe("classifying a failure", () => {
|
|
|
732
738
|
});
|
|
733
739
|
});
|
|
734
740
|
|
|
735
|
-
describe("the run's clock", () => {
|
|
736
|
-
test("is a STEP, so a replay does not re-measure it", async () => {
|
|
737
|
-
// A `Date.now()` in the body returns a different value on every replay, and
|
|
738
|
-
// every duration derived from it would be a different duration.
|
|
739
|
-
vi.useFakeTimers();
|
|
740
|
-
try {
|
|
741
|
-
vi.setSystemTime(1_700_000_000_000);
|
|
742
|
-
await expect(now()).resolves.toBe(1_700_000_000_000);
|
|
743
|
-
} finally {
|
|
744
|
-
vi.useRealTimers();
|
|
745
|
-
}
|
|
746
|
-
});
|
|
747
|
-
});
|
|
748
|
-
|
|
749
741
|
describe("the mastered narration", () => {
|
|
750
742
|
test("is not driven here, and the spec says why rather than pretending", () => {
|
|
751
743
|
// `narrate` speaks, writes a temp file, spawns ffmpeg and stores the result. A
|
|
@@ -821,6 +813,18 @@ describe("the ffmpeg steps, up to the spawn", () => {
|
|
|
821
813
|
});
|
|
822
814
|
|
|
823
815
|
describe("the body's step policy", () => {
|
|
816
|
+
/**
|
|
817
|
+
* A `ctx.now` that answers each reach from `samples`, in order.
|
|
818
|
+
*
|
|
819
|
+
* The last value repeats rather than running out: a spec naming two reads
|
|
820
|
+
* should fail on the DURATION if the body grows a third, not on an
|
|
821
|
+
* `undefined` from the fake.
|
|
822
|
+
*/
|
|
823
|
+
const clockReads = (...samples: readonly number[]) => {
|
|
824
|
+
let reach = 0;
|
|
825
|
+
return () => samples[Math.min(reach++, samples.length - 1)] ?? 0;
|
|
826
|
+
};
|
|
827
|
+
|
|
824
828
|
/**
|
|
825
829
|
* A `ctx` that walks the whole body without running a single step.
|
|
826
830
|
*
|
|
@@ -831,9 +835,13 @@ describe("the body's step policy", () => {
|
|
|
831
835
|
const walkedCtx = () =>
|
|
832
836
|
createWorkflowCtx({
|
|
833
837
|
runSteps: false,
|
|
838
|
+
// The body reads the clock at both ends with `ctx.now()`, which the engine
|
|
839
|
+
// journals under `now!0` and `now!1`. A PRODUCER here rather than a fixed
|
|
840
|
+
// number, so the two reaches differ and `elapsedMs` is assertable — based
|
|
841
|
+
// on `WORKFLOW_CTX_NOW`, the instant the fake otherwise freezes at, rather
|
|
842
|
+
// than on a second arbitrary epoch nobody can relate to the first.
|
|
843
|
+
now: clockReads(WORKFLOW_CTX_NOW, WORKFLOW_CTX_NOW + 3000),
|
|
834
844
|
results: {
|
|
835
|
-
clockStart: 1000,
|
|
836
|
-
clockEnd: 4000,
|
|
837
845
|
ingestRecording: {
|
|
838
846
|
audio: "upl_pcm",
|
|
839
847
|
source: "call.wav",
|
|
@@ -867,23 +875,94 @@ describe("the body's step policy", () => {
|
|
|
867
875
|
// other half of the claim: a raised budget is a decision about ONE step.
|
|
868
876
|
// Asserted as PRESENT-with-no-budget rather than as `get(…) === undefined`,
|
|
869
877
|
// which a step the body never reached at all would also satisfy.
|
|
870
|
-
for (const name of ["
|
|
878
|
+
for (const name of ["narrate", "summarize"]) {
|
|
871
879
|
expect(budgets.has(name)).toBe(true);
|
|
872
880
|
expect(budgets.get(name)).toBeUndefined();
|
|
873
881
|
}
|
|
874
882
|
});
|
|
875
883
|
|
|
876
|
-
test("reads the clock at each end
|
|
877
|
-
//
|
|
878
|
-
//
|
|
884
|
+
test("reads the clock with `ctx.now()` at each end, and subtracts in the body", async () => {
|
|
885
|
+
// Two reaches of one method, keyed `now!0` and `now!1` and journaled
|
|
886
|
+
// separately by the engine. This used to be two NAMED steps over an exported
|
|
887
|
+
// one-line clock read, and the names existed only so a person reading the
|
|
888
|
+
// run's history could tell the ends apart; the affordance journals the read
|
|
889
|
+
// itself. What is left to assert is that the body subtracts two journaled
|
|
890
|
+
// values rather than re-reading a clock, which is what makes a replay report
|
|
891
|
+
// the same elapsed.
|
|
879
892
|
const ctx = walkedCtx();
|
|
880
893
|
|
|
881
894
|
const output = await auditFlow({ recording: UPLOAD_ID }, ctx);
|
|
882
895
|
|
|
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
896
|
expect(output.elapsedMs).toBe(3000);
|
|
897
|
+
// And the clock is no longer a step, which is the other half of the claim: a
|
|
898
|
+
// name here would mean the migration left one behind.
|
|
899
|
+
expect(ctx.steps.map((step) => step.name)).not.toContain("clockStart");
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* `auditFlow` on the real replay engine — as far as this tier can take it.
|
|
905
|
+
*
|
|
906
|
+
* The block above drives the body through `createWorkflowCtx` with
|
|
907
|
+
* `runSteps: false` and a journaled result per step, which is what makes it
|
|
908
|
+
* affordable: `ingestRecording` runs ffmpeg, and this repo's test environment
|
|
909
|
+
* has none — the `ingestRecording` and `narrate` specs above assert the
|
|
910
|
+
* `FatalError` that produces, unconditionally.
|
|
911
|
+
*
|
|
912
|
+
* So a whole run of this desk is not reachable here and this file does not
|
|
913
|
+
* pretend otherwise; `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier
|
|
914
|
+
* with a built project. What IS reachable, and what nothing else asserts, is the
|
|
915
|
+
* ENGINE's half of the fatal/retryable contract: a step that throws a
|
|
916
|
+
* `FatalError` must fail its run on the first attempt rather than spending the
|
|
917
|
+
* six this body asks for. The step specs above assert the class; only a real
|
|
918
|
+
* engine can assert what the class is FOR.
|
|
919
|
+
*/
|
|
920
|
+
describe("the run is DURABLE, as far as ffmpeg allows", () => {
|
|
921
|
+
beforeEach(() => {
|
|
922
|
+
installStubUploads({
|
|
923
|
+
[UPLOAD_ID]: { bytes: new Uint8Array(2048), name: "call.m4a", type: "audio/mp4" },
|
|
924
|
+
});
|
|
925
|
+
installStubReporter();
|
|
926
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key");
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test("a FatalError in the first step fails the run on ONE attempt, not six", async () => {
|
|
930
|
+
const run = await runWorkflow(audit, { recording: UPLOAD_ID }, { name: "audit" });
|
|
931
|
+
|
|
932
|
+
expect(run.status).toBe("failed");
|
|
933
|
+
expect(run.error).toMatch(/ffmpeg/i);
|
|
934
|
+
const ingest = run.steps.find((step) => step.name === "ingestRecording");
|
|
935
|
+
// The whole point of `FatalError`: `maxAttempts: 6` is the budget this call
|
|
936
|
+
// site asks for, and a failure that cannot change must not spend it. A step
|
|
937
|
+
// that burned all six here would delay the real error by minutes in
|
|
938
|
+
// production and cost five more conversions of a file that is not going to
|
|
939
|
+
// convert.
|
|
940
|
+
expect(ingest?.status).toBe("failed");
|
|
941
|
+
expect(ingest?.attempts).toBe(1);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
test("the failure is DURABLE: a redelivery reports it rather than trying again", async () => {
|
|
945
|
+
const run = await runWorkflow(audit, { recording: UPLOAD_ID }, { name: "audit" });
|
|
946
|
+
expect(run.status).toBe("failed");
|
|
947
|
+
|
|
948
|
+
// A terminal run is done, and a redelivery of one is ordinary rather than an
|
|
949
|
+
// error — the platform's queue acks on a 200, so any delivery whose ack was
|
|
950
|
+
// lost arrives again after the run finished.
|
|
951
|
+
await run.restart();
|
|
952
|
+
expect(run.status).toBe("failed");
|
|
953
|
+
expect(run.steps.filter((step) => step.name === "ingestRecording")).toHaveLength(1);
|
|
954
|
+
expect(run.deliveries).toBe(2);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test("the clock is journaled BEFORE the step that fails, so the run's start survives", async () => {
|
|
958
|
+
// `ctx.now()` and the ingest are issued together in one `Promise.all`, so a
|
|
959
|
+
// failing ingest must not lose the clock read that went out beside it. It is
|
|
960
|
+
// journaled through the same append a step is, into a key space of its own —
|
|
961
|
+
// which is why it reads back off `reads` rather than `steps`.
|
|
962
|
+
const run = await runWorkflow(audit, { recording: UPLOAD_ID }, { name: "audit" });
|
|
963
|
+
|
|
964
|
+
const clock = run.reads.find((read) => read.key === "now!0");
|
|
965
|
+
expect(clock?.kind).toBe("now");
|
|
966
|
+
expect(typeof clock?.value).toBe("number");
|
|
888
967
|
});
|
|
889
968
|
});
|