@alexkroman1/aai-cli 13.2.0 → 14.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/bin.mjs +1 -1
- package/dist/{_agent-BTwZJu4P.mjs → _agent-BzUeqOdj.mjs} +33 -10
- package/dist/_agent.d.ts +23 -0
- package/dist/{_bundler-C31rlcYz.mjs → _bundler-DolUCMxu.mjs} +9 -8
- package/dist/_cli-common.d.ts +45 -6
- package/dist/{_config-CmJOFsAP.mjs → _config-DYzC6WMD.mjs} +7 -4
- package/dist/_config.d.ts +25 -1
- package/dist/_dev-env.d.ts +1 -1
- package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-CSMqF8PN.mjs} +28 -30
- package/dist/_dev-vite-config.d.ts +3 -3
- package/dist/_e2e-test-utils.d.ts +15 -7
- package/dist/{_init-BURbdHNM.mjs → _init-CQ8idAwo.mjs} +64 -30
- package/dist/{_output-CKkmWs7i.mjs → _output-DBP9Op_d.mjs} +43 -3
- package/dist/_output.d.ts +24 -7
- package/dist/{_preflight-Co1G9Jww.mjs → _preflight-CcAR577V.mjs} +3 -3
- package/dist/_resource-commands.d.ts +9 -9
- package/dist/{_server-common-6e0QI4mq.mjs → _server-common-vILJp3it.mjs} +3 -3
- package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-CGvc2YSQ.mjs} +19 -10
- package/dist/_slug-api.d.ts +19 -0
- package/dist/{_studio-B1waXMuR.mjs → _studio-DnR_BqFp.mjs} +9 -5
- package/dist/_studio-commands.d.ts +13 -13
- package/dist/{_templates-0Zr2z6yA.mjs → _templates-CK4oKoeX.mjs} +3 -3
- package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-U3jcil8L.mjs} +5 -4
- package/dist/_typecheck-gate.d.ts +3 -1
- package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
- package/dist/{_utils-B8QmtFhK.mjs → _utils-D5JGcjiW.mjs} +44 -2
- package/dist/_utils.d.ts +35 -0
- package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
- package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-CvpqMpdh.mjs} +19 -27
- package/dist/_workflow-determinism.d.ts +3 -3
- package/dist/build-Mxk8gWvX.mjs +108 -0
- package/dist/build.d.ts +5 -0
- package/dist/cli.mjs +172 -116
- package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-BJgREAh6.mjs} +4 -4
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-X_CRgCf6.mjs → delete-BdFnbnVR.mjs} +6 -6
- package/dist/{deploy-BzZ-r4iM.mjs → deploy-uAJ4NukN.mjs} +14 -14
- package/dist/{dev-BDykFvcc.mjs → dev-DApPSaE_.mjs} +5 -5
- package/dist/{eval-0qEOdm0l.mjs → eval-BK47A_K5.mjs} +27 -11
- package/dist/eval.d.ts +2 -0
- package/dist/{init-BtJI306C.mjs → init-DukDxECd.mjs} +13 -21
- package/dist/{login-DeUETobb.mjs → login-DaLrPOFg.mjs} +6 -8
- package/dist/{logs-hWnJ66Bl.mjs → logs-7BRwq0cG.mjs} +22 -15
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/.env.example +17 -1
- package/dist/scaffold/CLAUDE.md +84 -56
- package/dist/scaffold/package.json +6 -5
- package/dist/scaffold/server.mjs +129 -25
- package/dist/secret-Cjp912yF.mjs +162 -0
- package/dist/secret.d.ts +48 -13
- package/dist/{studio-BxAS_FQQ.mjs → studio-CpHlNHUZ.mjs} +15 -17
- package/dist/templates/briefing-desk/agent.test.ts +7 -3
- package/dist/templates/call-audit/agent.test.ts +25 -9
- package/dist/templates/call-audit/agent.ts +2 -2
- package/dist/templates/call-audit/client.tsx +40 -58
- package/dist/templates/call-audit/workflows/audit.ts +7 -5
- package/dist/templates/call-audit/workflows/ingest.ts +7 -7
- package/dist/templates/call-audit/workflows/media.ts +12 -37
- package/dist/templates/call-audit/workflows/summarize.ts +8 -8
- package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
- package/dist/templates/dispatch-center/agent.test.ts +7 -7
- package/dist/templates/dispatch-center/client.tsx +53 -61
- package/dist/templates/dispatch-center/shared.ts +0 -8
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
- package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
- package/dist/templates/embedded-assets/agent.test.ts +7 -1
- package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
- package/dist/templates/health-assistant/agent.test.ts +8 -4
- package/dist/templates/infocom-adventure/client.tsx +40 -36
- package/dist/templates/link-digest/agent.test.ts +10 -4
- package/dist/templates/link-digest/agent.ts +2 -2
- package/dist/templates/link-digest/client.tsx +42 -36
- package/dist/templates/link-digest/workflows/digest.ts +13 -16
- package/dist/templates/night-owl/agent.eval.test.ts +4 -2
- package/dist/templates/night-owl/agent.test.ts +6 -1
- package/dist/templates/night-owl/client.tsx +8 -2
- package/dist/templates/pipeline-simple/agent.test.ts +33 -16
- package/dist/templates/pizza-ordering/client.tsx +5 -4
- package/dist/templates/pizza-ordering/shared.ts +3 -8
- package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
- package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
- package/dist/templates/plan-and-execute/agent.test.ts +10 -5
- package/dist/templates/plan-and-execute/client.tsx +2 -2
- package/dist/templates/podcast-digest/agent.test.ts +33 -14
- package/dist/templates/podcast-digest/client.tsx +27 -36
- package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
- package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
- package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
- package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
- package/dist/templates/recap-workflow/agent.test.ts +49 -32
- package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
- package/dist/templates/redline/agent.eval.test.ts +5 -8
- package/dist/templates/redline/agent.test.ts +8 -2
- package/dist/templates/redline/client.tsx +47 -48
- package/dist/templates/redline/workflows/redline.ts +17 -17
- package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
- package/dist/templates/research-workflow/agent.test.ts +17 -8
- package/dist/templates/research-workflow/workflows/research.ts +28 -30
- package/dist/templates/retail/agent.test.ts +17 -15
- package/dist/templates/retail/cancel.ts +7 -6
- package/dist/templates/retail/client.tsx +39 -58
- package/dist/templates/retail/payment.ts +4 -3
- package/dist/templates/retail/pending.test.ts +19 -19
- package/dist/templates/retail/registry.test.ts +99 -58
- package/dist/templates/retail/store.test.ts +5 -5
- package/dist/templates/retail/swap.ts +8 -11
- package/dist/templates/simple/agent.test.ts +39 -6
- package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
- package/dist/templates/solo-rpg/agent.test.ts +12 -12
- package/dist/templates/solo-rpg/client.tsx +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +21 -12
- package/dist/templates/spoken-summary/agent.ts +3 -3
- package/dist/templates/spoken-summary/client.tsx +36 -47
- package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
- package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
- package/dist/templates/support-line/client.tsx +4 -3
- package/dist/templates/support-line/prompts.ts +0 -2
- package/dist/templates/transcription-workflow/agent.test.ts +36 -20
- package/dist/templates/transcription-workflow/agent.ts +2 -2
- package/dist/templates/transcription-workflow/client.tsx +34 -40
- package/dist/templates/transcription-workflow/recover.ts +1 -1
- package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
- package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
- package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
- package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
- package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
- package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
- package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
- package/dist/templates/travel-concierge/agent.test.ts +15 -7
- package/dist/templates/travel-concierge/client.tsx +2 -2
- package/dist/templates/travel-concierge/shared.ts +10 -14
- package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
- package/dist/test-D032V2cA.mjs +242 -0
- package/dist/test.d.ts +97 -31
- package/dist/typecheck.mjs +2 -2
- package/dist/worker-bundler.mjs +3 -3
- package/dist/{workflow-BBu5oEDw.mjs → workflow-CVioyO3V.mjs} +8 -7
- package/package.json +10 -10
- package/dist/bin.d.mts +0 -2
- package/dist/build-BLlLOrng.mjs +0 -66
- package/dist/secret-B-3T5tvr.mjs +0 -61
- package/dist/test-CiLab-AA.mjs +0 -182
- package/dist/tsdown.config.d.ts +0 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { a as ok, n as fail, t as CliError } from "./_output-DBP9Op_d.mjs";
|
|
3
|
+
import { n as log, s as unwrapCancel } from "./_ui-DCt4qZrk.mjs";
|
|
4
|
+
import { f as checkedResponse, p as isStringArray } from "./_studio-DnR_BqFp.mjs";
|
|
5
|
+
import { t as secretRequest } from "./_slug-api-CGvc2YSQ.mjs";
|
|
6
|
+
import { isRecord, plural } from "@alexkroman1/aai/utils";
|
|
7
|
+
import * as p from "@clack/prompts";
|
|
8
|
+
import pTimeout from "p-timeout";
|
|
9
|
+
//#region src/secret.ts
|
|
10
|
+
/**
|
|
11
|
+
* The one `no_input` failure for `secret put`, shared by every path that can
|
|
12
|
+
* reach it — the stdin read that came back empty, the refusal when stdin is a
|
|
13
|
+
* terminal and JSON mode cannot prompt, and the prompt the user dismissed —
|
|
14
|
+
* so the three cannot drift.
|
|
15
|
+
*
|
|
16
|
+
* It is built here rather than kept as a fixed tuple so the hint can name the
|
|
17
|
+
* secret AND both ways of supplying a value: that sentence is the contract,
|
|
18
|
+
* and until this commit it appeared nowhere — not in `--help`, not in the
|
|
19
|
+
* scaffolded README (which said it prompts), and not in the failure itself,
|
|
20
|
+
* which said only "Pipe secret value to stdin".
|
|
21
|
+
*/
|
|
22
|
+
function noInput(name, detail) {
|
|
23
|
+
return {
|
|
24
|
+
code: "no_input",
|
|
25
|
+
message: `No value provided for ${name} (${detail})`,
|
|
26
|
+
hint: `Pipe the value in — \`printf %s "$VALUE" | aai secret put ${name}\` — or run the command on a terminal to be prompted for it.`
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* How long stdin may stay SILENT before `secret put` gives up on it.
|
|
31
|
+
*
|
|
32
|
+
* It bounds only the FIRST byte — a producer that has started is then given
|
|
33
|
+
* as long as it likes, because a secret usually comes out of something slow
|
|
34
|
+
* (`pass show`, `op read`, a KMS call) and cutting it off mid-value would be
|
|
35
|
+
* worse than waiting.
|
|
36
|
+
*
|
|
37
|
+
* What the bound is for is stdin with no producer behind it at all: an
|
|
38
|
+
* inherited-but-idle pipe, which is what an agent harness, a CI step, a
|
|
39
|
+
* supervisor and an editor task all hand a child. Reading to EOF is correct
|
|
40
|
+
* and the EOF was never coming, so the command printed NOTHING and blocked
|
|
41
|
+
* forever — on the only documented way to get a credential into production.
|
|
42
|
+
* A bounded wait that ends in a sentence naming both working forms is the
|
|
43
|
+
* whole difference between "unusable" and "I typed it wrong".
|
|
44
|
+
*/
|
|
45
|
+
const STDIN_FIRST_BYTE_MS = 1e4;
|
|
46
|
+
/**
|
|
47
|
+
* Read stdin to EOF, giving up if nothing arrives at all — `undefined` for
|
|
48
|
+
* that case, distinct from `""` for a producer that sent nothing and closed.
|
|
49
|
+
*
|
|
50
|
+
* Hand-rolled rather than `text(process.stdin)` because the deadline has to
|
|
51
|
+
* apply to the first chunk and NOT to the whole read; `p-timeout`'s
|
|
52
|
+
* `message: false` resolves `undefined` instead of rejecting, so there is no
|
|
53
|
+
* timer to clear and no losing branch to reject late.
|
|
54
|
+
*/
|
|
55
|
+
async function readStdinToEnd(stdin, firstByteMs) {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
const firstByte = Promise.withResolvers();
|
|
58
|
+
const ended = Promise.withResolvers();
|
|
59
|
+
const onData = (chunk) => {
|
|
60
|
+
chunks.push(chunk);
|
|
61
|
+
firstByte.resolve(true);
|
|
62
|
+
};
|
|
63
|
+
const onEnd = () => {
|
|
64
|
+
firstByte.resolve(true);
|
|
65
|
+
ended.resolve();
|
|
66
|
+
};
|
|
67
|
+
const onError = (err) => {
|
|
68
|
+
firstByte.reject(err);
|
|
69
|
+
ended.reject(err);
|
|
70
|
+
};
|
|
71
|
+
stdin.on("data", onData).once("end", onEnd).once("error", onError);
|
|
72
|
+
try {
|
|
73
|
+
if (!await pTimeout(firstByte.promise, {
|
|
74
|
+
milliseconds: firstByteMs,
|
|
75
|
+
message: false
|
|
76
|
+
})) return void 0;
|
|
77
|
+
await ended.promise;
|
|
78
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
79
|
+
} finally {
|
|
80
|
+
stdin.off("data", onData).off("end", onEnd).off("error", onError);
|
|
81
|
+
stdin.pause();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The value for `secret put`, from whichever source is actually available —
|
|
86
|
+
* or `undefined` to mean "prompt", which {@link executeSecretPut} does.
|
|
87
|
+
*
|
|
88
|
+
* Keyed off STDIN, deliberately, and never off the output mode. A stdin read
|
|
89
|
+
* resolves at EOF and a terminal never sends one, so keying the read off
|
|
90
|
+
* `mode === "json"` — which is decided by STDOUT — made `aai secret put NAME`
|
|
91
|
+
* print NOTHING and block forever in every context where stdout is not a
|
|
92
|
+
* terminal but stdin still is: a CI step, `| tee`, an agent harness, an
|
|
93
|
+
* editor task. That is the worst failure a CLI can have on the only
|
|
94
|
+
* documented way to get a credential into production, and it is why each of
|
|
95
|
+
* the three cases below is answered at once:
|
|
96
|
+
*
|
|
97
|
+
* - stdin is a pipe or a file: read it to EOF — the documented contract —
|
|
98
|
+
* bounded on the FIRST byte so an idle inherited pipe cannot block the
|
|
99
|
+
* command forever (see {@link STDIN_FIRST_BYTE_MS}). An empty read is
|
|
100
|
+
* refused rather than sent to the server.
|
|
101
|
+
* - stdin is a terminal, human output: `undefined`, so the executor prompts.
|
|
102
|
+
* - stdin is a terminal, JSON output: refused immediately. JSON mode promises
|
|
103
|
+
* exactly one result line on stdout and cannot spend it on a prompt — so it
|
|
104
|
+
* says which two forms do work instead of waiting for an EOF that a person
|
|
105
|
+
* sitting at a terminal has no reason to send.
|
|
106
|
+
*/
|
|
107
|
+
async function resolveSecretValue(name, mode, io = {}) {
|
|
108
|
+
if (!(io.stdinIsTTY ?? Boolean(process.stdin.isTTY))) {
|
|
109
|
+
const firstByteMs = io.firstByteMs ?? STDIN_FIRST_BYTE_MS;
|
|
110
|
+
const piped = await readStdinToEnd(io.stdin ?? process.stdin, firstByteMs);
|
|
111
|
+
if (piped) return piped;
|
|
112
|
+
const { code, message, hint } = noInput(name, piped === void 0 ? `nothing arrived on stdin within ${Math.round(firstByteMs / 1e3)}s` : "stdin was empty");
|
|
113
|
+
throw new CliError(code, message, hint);
|
|
114
|
+
}
|
|
115
|
+
if (mode === "json") {
|
|
116
|
+
const { code, message, hint } = noInput(name, "stdin is a terminal and --json cannot prompt");
|
|
117
|
+
throw new CliError(code, message, hint);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Execute secret put. If `value` is provided, use it directly (the stdin
|
|
122
|
+
* path). If not, prompt for it — masked — which is what
|
|
123
|
+
* {@link resolveSecretValue} returning `undefined` asks for.
|
|
124
|
+
*/
|
|
125
|
+
async function executeSecretPut(cwd, name, value, server) {
|
|
126
|
+
let secretValue = value;
|
|
127
|
+
if (!secretValue) {
|
|
128
|
+
const result = unwrapCancel(await p.password({ message: `Enter value for ${name}` }));
|
|
129
|
+
if (!result) {
|
|
130
|
+
const { code, message, hint } = noInput(name, "the prompt came back empty");
|
|
131
|
+
return fail(code, message, hint);
|
|
132
|
+
}
|
|
133
|
+
secretValue = result;
|
|
134
|
+
}
|
|
135
|
+
const { target } = await secretRequest(cwd, "", {
|
|
136
|
+
method: "PUT",
|
|
137
|
+
body: { [name]: secretValue },
|
|
138
|
+
action: "secret"
|
|
139
|
+
}, server);
|
|
140
|
+
log.success(`Set ${name} for ${target}`);
|
|
141
|
+
return ok({ name });
|
|
142
|
+
}
|
|
143
|
+
async function executeSecretDelete(cwd, name, server) {
|
|
144
|
+
const { target } = await secretRequest(cwd, `/${encodeURIComponent(name)}`, {
|
|
145
|
+
method: "DELETE",
|
|
146
|
+
action: "secret"
|
|
147
|
+
}, server);
|
|
148
|
+
log.success(`Deleted ${name} from ${target}`);
|
|
149
|
+
return ok({ name });
|
|
150
|
+
}
|
|
151
|
+
async function executeSecretList(cwd, server) {
|
|
152
|
+
const { data, target } = await secretRequest(cwd, "", { action: "secret" }, server);
|
|
153
|
+
const { vars } = checkedResponse(data, (value) => isRecord(value) && isStringArray(value.vars), `the secret list for ${target}`);
|
|
154
|
+
if (vars.length === 0) log.info("No secrets set. Use `aai secret put <name>` to add one.");
|
|
155
|
+
else {
|
|
156
|
+
log.message(`${vars.length} ${plural(vars.length, "secret")}:`);
|
|
157
|
+
for (const v of vars) log.message(` ${v}`);
|
|
158
|
+
}
|
|
159
|
+
return ok({ secrets: vars });
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
export { executeSecretDelete, executeSecretList, executeSecretPut, resolveSecretValue };
|
package/dist/secret.d.ts
CHANGED
|
@@ -1,25 +1,60 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import { type CommandResult, type OutputMode } from "./_output.ts";
|
|
2
3
|
/**
|
|
3
|
-
* The
|
|
4
|
-
*
|
|
4
|
+
* The environment {@link resolveSecretValue} branches on, as a seam a spec can
|
|
5
|
+
* supply — a `Readable` rather than a fake reader function, so a test drives
|
|
6
|
+
* the real wait (the part that blocked) and needs no cast to stand in for
|
|
7
|
+
* `process.stdin`.
|
|
5
8
|
*/
|
|
6
|
-
export
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
export type SecretValueIo = {
|
|
10
|
+
/** Defaults to `process.stdin`. */
|
|
11
|
+
stdin?: Readable | undefined;
|
|
12
|
+
/** Defaults to whether the real stdin is a terminal. */
|
|
13
|
+
stdinIsTTY?: boolean | undefined;
|
|
14
|
+
/** Defaults to {@link STDIN_FIRST_BYTE_MS}. */
|
|
15
|
+
firstByteMs?: number | undefined;
|
|
11
16
|
};
|
|
12
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The value for `secret put`, from whichever source is actually available —
|
|
19
|
+
* or `undefined` to mean "prompt", which {@link executeSecretPut} does.
|
|
20
|
+
*
|
|
21
|
+
* Keyed off STDIN, deliberately, and never off the output mode. A stdin read
|
|
22
|
+
* resolves at EOF and a terminal never sends one, so keying the read off
|
|
23
|
+
* `mode === "json"` — which is decided by STDOUT — made `aai secret put NAME`
|
|
24
|
+
* print NOTHING and block forever in every context where stdout is not a
|
|
25
|
+
* terminal but stdin still is: a CI step, `| tee`, an agent harness, an
|
|
26
|
+
* editor task. That is the worst failure a CLI can have on the only
|
|
27
|
+
* documented way to get a credential into production, and it is why each of
|
|
28
|
+
* the three cases below is answered at once:
|
|
29
|
+
*
|
|
30
|
+
* - stdin is a pipe or a file: read it to EOF — the documented contract —
|
|
31
|
+
* bounded on the FIRST byte so an idle inherited pipe cannot block the
|
|
32
|
+
* command forever (see {@link STDIN_FIRST_BYTE_MS}). An empty read is
|
|
33
|
+
* refused rather than sent to the server.
|
|
34
|
+
* - stdin is a terminal, human output: `undefined`, so the executor prompts.
|
|
35
|
+
* - stdin is a terminal, JSON output: refused immediately. JSON mode promises
|
|
36
|
+
* exactly one result line on stdout and cannot spend it on a prompt — so it
|
|
37
|
+
* says which two forms do work instead of waiting for an EOF that a person
|
|
38
|
+
* sitting at a terminal has no reason to send.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveSecretValue(name: string, mode: OutputMode, io?: SecretValueIo): Promise<string | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* Both the put and the delete result — one shape, so one name. They were two
|
|
43
|
+
* distinct aliases for `{ name: string }`, which implied a difference that does
|
|
44
|
+
* not exist and left "which alias meant the secret result" to be re-decided.
|
|
45
|
+
*/
|
|
46
|
+
type SecretNameData = {
|
|
13
47
|
name: string;
|
|
14
48
|
};
|
|
15
49
|
type SecretListData = {
|
|
16
50
|
secrets: string[];
|
|
17
51
|
};
|
|
18
52
|
/**
|
|
19
|
-
* Execute secret put. If `value` is provided, use it directly (
|
|
20
|
-
* If not
|
|
53
|
+
* Execute secret put. If `value` is provided, use it directly (the stdin
|
|
54
|
+
* path). If not, prompt for it — masked — which is what
|
|
55
|
+
* {@link resolveSecretValue} returning `undefined` asks for.
|
|
21
56
|
*/
|
|
22
|
-
export declare function executeSecretPut(cwd: string, name: string, value: string | undefined, server: string | undefined): Promise<CommandResult<
|
|
23
|
-
export declare function executeSecretDelete(cwd: string, name: string, server: string | undefined): Promise<CommandResult<
|
|
57
|
+
export declare function executeSecretPut(cwd: string, name: string, value: string | undefined, server: string | undefined): Promise<CommandResult<SecretNameData>>;
|
|
58
|
+
export declare function executeSecretDelete(cwd: string, name: string, server: string | undefined): Promise<CommandResult<SecretNameData>>;
|
|
24
59
|
export declare function executeSecretList(cwd: string, server: string | undefined): Promise<CommandResult<SecretListData>>;
|
|
25
60
|
export {};
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as ok, t as CliError } from "./_output-
|
|
3
|
-
import { n as log, t as fmtUrl } from "./_ui-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
2
|
+
import { a as ok, t as CliError } from "./_output-DBP9Op_d.mjs";
|
|
3
|
+
import { n as log, t as fmtUrl } from "./_ui-DCt4qZrk.mjs";
|
|
4
|
+
import { l as formatCappedList } from "./_utils-D5JGcjiW.mjs";
|
|
5
|
+
import { s as updateProjectConfig } from "./_config-DYzC6WMD.mjs";
|
|
6
|
+
import { t as resolveServerEnv } from "./_server-common-vILJp3it.mjs";
|
|
7
|
+
import { o as resolveDeployTarget } from "./_agent-BzUeqOdj.mjs";
|
|
8
|
+
import { layerScaffold } from "./_templates-CK4oKoeX.mjs";
|
|
9
|
+
import { a as publishStudioProject, c as studioProjectUrl, f as checkedResponse, i as projectNameFromDir, n as fetchStudioProject, o as pushStudioSource, r as listStudioProjects, s as studioProjectApiUrl, t as collectSourceFiles, u as apiRequest } from "./_studio-DnR_BqFp.mjs";
|
|
9
10
|
import { existsSync } from "node:fs";
|
|
10
11
|
import path from "node:path";
|
|
11
|
-
import { isRecord, omitUndefined } from "@alexkroman1/aai/utils";
|
|
12
|
+
import { isRecord, omitUndefined, plural } from "@alexkroman1/aai/utils";
|
|
12
13
|
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
13
14
|
import { isPathInside } from "@alexkroman1/aai-runtime/internal";
|
|
14
|
-
//#region studio.ts
|
|
15
|
+
//#region src/studio.ts
|
|
15
16
|
/**
|
|
16
17
|
* The studio-workspace commands: `aai list`, `aai pull`, `aai push`,
|
|
17
18
|
* `aai publish`.
|
|
@@ -62,7 +63,7 @@ async function notFoundHint(serverUrl, apiKey) {
|
|
|
62
63
|
const projects = await listStudioProjects(serverUrl, apiKey).catch(() => null);
|
|
63
64
|
if (projects === null) return "Run `aai list` to see your projects.";
|
|
64
65
|
if (projects.length === 0) return "This login has no studio projects at all. If yours are in the studio, the CLI is linked to a different account — run `aai login` again, approve it in a browser signed in to the account that owns the project, then `aai list`.";
|
|
65
|
-
return `Your projects: ${projects
|
|
66
|
+
return `Your projects: ${formatCappedList(projects)}.`;
|
|
66
67
|
}
|
|
67
68
|
async function executePull(opts) {
|
|
68
69
|
const { serverUrl, apiKey } = await resolveDeployTarget(opts.cwd, opts.server);
|
|
@@ -159,17 +160,14 @@ async function syncEnvSecrets(cwd, serverUrl, apiKey, project) {
|
|
|
159
160
|
method: "PUT",
|
|
160
161
|
body: env
|
|
161
162
|
});
|
|
162
|
-
log.info(`Synced ${names.length}
|
|
163
|
+
log.info(`Synced ${names.length} ${plural(names.length, "secret")} from .env`);
|
|
163
164
|
return names;
|
|
164
165
|
}
|
|
165
166
|
async function executePublish(opts) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
await assertTypechecks(opts.cwd);
|
|
169
|
-
}
|
|
167
|
+
const { assertTypechecks } = await import("./_typecheck-gate-U3jcil8L.mjs");
|
|
168
|
+
await assertTypechecks(opts.cwd, { skip: opts.skipTypecheck });
|
|
170
169
|
const pushed = await pushProject(opts);
|
|
171
170
|
const { project, serverUrl, apiKey } = pushed;
|
|
172
|
-
const hadSlug = pushed.slug !== void 0;
|
|
173
171
|
if (pushed.slug) await syncEnvSecrets(opts.cwd, serverUrl, apiKey, project);
|
|
174
172
|
log.step(`Publishing ${project} (builds in the project's sandbox)…`);
|
|
175
173
|
const result = checkedResponse(await publishStudioProject(serverUrl, apiKey, project, { skipTypecheck: opts.skipTypecheck }), (value) => isRecord(value) && typeof value.slug === "string" && typeof value.output === "string", `the publish route at ${serverUrl}`);
|
|
@@ -178,7 +176,7 @@ async function executePublish(opts) {
|
|
|
178
176
|
serverUrl,
|
|
179
177
|
slug: result.slug
|
|
180
178
|
});
|
|
181
|
-
if (!
|
|
179
|
+
if (!pushed.slug) {
|
|
182
180
|
if ((await syncEnvSecrets(opts.cwd, serverUrl, apiKey, result.slug)).length > 0) log.info("They apply on the next `aai publish`.");
|
|
183
181
|
}
|
|
184
182
|
const agentUrl = `${serverUrl}/${result.slug}`;
|
|
@@ -63,10 +63,14 @@ function desk(
|
|
|
63
63
|
|
|
64
64
|
describe("the desk itself", () => {
|
|
65
65
|
test("has no web tools of its own — everything goes through a subagent", () => {
|
|
66
|
-
|
|
67
|
-
//
|
|
66
|
+
// Stated as the claim the test's name makes, rather than as "no builtins at
|
|
67
|
+
// all": giving the desk `run_code` is a reasonable edit, and it does not
|
|
68
|
+
// put the web in front of the desk.
|
|
69
|
+
expect(authoredAgent.builtinTools ?? []).not.toContain("web_search");
|
|
70
|
+
expect(authoredAgent.builtinTools ?? []).not.toContain("visit_webpage");
|
|
71
|
+
// And the subagents do, which is the split the template exists to show.
|
|
68
72
|
expect(researcher.builtinTools).toContain("web_search");
|
|
69
|
-
expect(factChecker.builtinTools).
|
|
73
|
+
expect(factChecker.builtinTools).toContain("web_search");
|
|
70
74
|
});
|
|
71
75
|
|
|
72
76
|
test("gives the checker a tighter budget than the researcher", () => {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import { readdir } from "node:fs/promises";
|
|
33
33
|
import { tmpdir } from "node:os";
|
|
34
34
|
import { FatalError, RetryableError } from "@alexkroman1/aai/step-errors";
|
|
35
|
-
import {
|
|
35
|
+
import { createWorkflowContext, stubSpeech, WORKFLOW_CONTEXT_NOW } from "@alexkroman1/aai/testing";
|
|
36
36
|
import {
|
|
37
37
|
installStubGateway,
|
|
38
38
|
installStubReporter,
|
|
@@ -182,15 +182,26 @@ describe("the declaration", () => {
|
|
|
182
182
|
test("is a workflow app with the one workflow the page starts by name", () => {
|
|
183
183
|
// The page calls `api.start("audit", …)`, so a rename here is a runtime 400
|
|
184
184
|
// rather than a compile error. This is what pins it.
|
|
185
|
-
|
|
185
|
+
// `toContain` rather than an exact key list: adding a second workflow is an
|
|
186
|
+
// invited edit and must not redden a test the author did not write. The
|
|
187
|
+
// NAME is still pinned, deliberately — the page starts a run by this
|
|
188
|
+
// string, so renaming the key is a runtime 400 rather than a compile
|
|
189
|
+
// error, and this pin is the only thing that says so. Rename it here and
|
|
190
|
+
// in `client.tsx` together.
|
|
191
|
+
expect(Object.keys(agentDef.workflows ?? {})).toContain("audit");
|
|
192
|
+
expect(agentDef.workflows?.audit).toBe(audit);
|
|
186
193
|
});
|
|
187
194
|
|
|
188
|
-
test("declares
|
|
195
|
+
test("declares the credential its steps read, so a deploy checks for it", () => {
|
|
189
196
|
// A workflow app has no session, so nothing else in its config could name one
|
|
190
197
|
// — and one AssemblyAI key covers transcription, the model and the voice.
|
|
191
198
|
// Note what is NOT here: ffmpeg. `requiredEnv` checks the environment, and a
|
|
192
199
|
// binary on `PATH` is not an environment variable.
|
|
193
|
-
|
|
200
|
+
//
|
|
201
|
+
// `toContain` rather than an exact list: a step of your own that reads a
|
|
202
|
+
// second credential belongs in `requiredEnv` beside this one, and declaring
|
|
203
|
+
// it must not fail this test.
|
|
204
|
+
expect(agentDef.requiredEnv).toContain("ASSEMBLYAI_API_KEY");
|
|
194
205
|
});
|
|
195
206
|
|
|
196
207
|
test("takes the recording as an UPLOAD, which is what makes the form a file picker", () => {
|
|
@@ -505,7 +516,7 @@ describe("planning where to cut", () => {
|
|
|
505
516
|
test("the last segment never addresses a byte the file does not have", () => {
|
|
506
517
|
// The twelve-byte bug this API shape exists to prevent: planning from a
|
|
507
518
|
// duration rounded to whole milliseconds put `endByte` at 640,512 for a
|
|
508
|
-
// 640,500-byte file. `
|
|
519
|
+
// 640,500-byte file. `stepReadUpload` clamps a window to the stored size, so
|
|
509
520
|
// nothing threw — the plan was simply describing audio that does not exist.
|
|
510
521
|
// Found by running the real argv against a real ffmpeg, which is the only
|
|
511
522
|
// place a twelve-byte error was ever going to surface.
|
|
@@ -856,14 +867,14 @@ describe("the body's step policy", () => {
|
|
|
856
867
|
* only reads `ctx.steps` afterwards, so it is built once.
|
|
857
868
|
*/
|
|
858
869
|
const walkedCtx = () =>
|
|
859
|
-
|
|
870
|
+
createWorkflowContext({
|
|
860
871
|
runSteps: false,
|
|
861
872
|
// The body reads the clock at both ends with `ctx.now()`, which the engine
|
|
862
873
|
// journals under `now!0` and `now!1`. A PRODUCER here rather than a fixed
|
|
863
874
|
// number, so the two reaches differ and `elapsedMs` is assertable — based
|
|
864
|
-
// on `
|
|
875
|
+
// on `WORKFLOW_CONTEXT_NOW`, the instant the fake otherwise freezes at, rather
|
|
865
876
|
// than on a second arbitrary epoch nobody can relate to the first.
|
|
866
|
-
now: clockReads(
|
|
877
|
+
now: clockReads(WORKFLOW_CONTEXT_NOW, WORKFLOW_CONTEXT_NOW + 3000),
|
|
867
878
|
results: {
|
|
868
879
|
ingestRecording: {
|
|
869
880
|
audio: "upl_pcm",
|
|
@@ -926,7 +937,7 @@ describe("the body's step policy", () => {
|
|
|
926
937
|
/**
|
|
927
938
|
* `auditFlow` on the real replay engine — as far as this tier can take it.
|
|
928
939
|
*
|
|
929
|
-
* The block above drives the body through `
|
|
940
|
+
* The block above drives the body through `createWorkflowContext` with
|
|
930
941
|
* `runSteps: false` and a journaled result per step, which is what makes it
|
|
931
942
|
* affordable: `ingestRecording` shells out to the ffmpeg toolchain, which this
|
|
932
943
|
* tier cannot usefully feed — the `ingestRecording` and `narrate` specs above
|
|
@@ -959,6 +970,11 @@ describe("the run is DURABLE, as far as ffmpeg allows", () => {
|
|
|
959
970
|
});
|
|
960
971
|
installStubReporter();
|
|
961
972
|
vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key");
|
|
973
|
+
// Neither binary is this block's subject (see its doc), and a real
|
|
974
|
+
// conversion here would make a developer with ffmpeg installed run a
|
|
975
|
+
// slower, different test from CI, which has none.
|
|
976
|
+
vi.stubEnv("AAI_FFMPEG_PATH", "/nonexistent/ffmpeg");
|
|
977
|
+
vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/ffprobe");
|
|
962
978
|
});
|
|
963
979
|
|
|
964
980
|
test("a FatalError in the first step fails the run on ONE attempt, not six", async () => {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* - `transcription-workflow` owns the FAN-OUT — why the sync transcription
|
|
21
21
|
* endpoint's 120-second cap forces one, and how a run survives dying on segment
|
|
22
22
|
* 27 of 60.
|
|
23
|
-
* - `spoken-summary` owns the audio ROUND TRIP — `stepSpeak`, `
|
|
23
|
+
* - `spoken-summary` owns the audio ROUND TRIP — `stepSpeak`, `stepWriteUpload`, and
|
|
24
24
|
* why a page needs `api.download` rather than a URL.
|
|
25
25
|
*
|
|
26
26
|
* **What this template adds is a decoder**, and everything downstream changes
|
|
@@ -153,7 +153,7 @@ export const audit: WorkflowDef<typeof auditInput, CallAudit> = workflow({
|
|
|
153
153
|
input: auditInput,
|
|
154
154
|
// The one line that makes the form take a file: `<WorkflowFields>` renders a
|
|
155
155
|
// picker for this property, `useWorkflowSubmit` stores the chosen file, and the
|
|
156
|
-
// ingest step reads it back with `
|
|
156
|
+
// ingest step reads it back with `stepReadUpload`.
|
|
157
157
|
uploads: ["recording"],
|
|
158
158
|
run: auditFlow,
|
|
159
159
|
});
|
|
@@ -30,15 +30,16 @@
|
|
|
30
30
|
* cutting and auditing. On this template that is the most expensive orphaning
|
|
31
31
|
* in `templates/`: the recording is already stored, so the work is paid for, and
|
|
32
32
|
* a page with no handle on it invites somebody to upload a 700 MB call a second
|
|
33
|
-
* time and run the whole pipeline again.
|
|
34
|
-
*
|
|
33
|
+
* time and run the whole pipeline again. The handle that survives a reload is a
|
|
34
|
+
* correlation KEY, and this desk passes none: `useWorkflowSubmit` mints one,
|
|
35
|
+
* records every run under it, and asks for that key's newest run as it mounts.
|
|
35
36
|
*
|
|
36
|
-
* The upload half of a reload is
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
37
|
+
* The upload half of a reload is the same hook's: it remembers the id it
|
|
38
|
+
* minted, so picking the same file again sends only the windows that did not
|
|
39
|
+
* land. Both halves therefore have the same LIFETIME — `sessionStorage`, one
|
|
40
|
+
* store — which is the first reason this desk wants the default rather than a
|
|
41
|
+
* key of its own. A handle that outlived it would promise a return the other
|
|
42
|
+
* half cannot keep.
|
|
42
43
|
*
|
|
43
44
|
* ## Why the artifact being shareable does NOT make the key shareable
|
|
44
45
|
*
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
* needs none of it — the page renders the audit and offers `Download audit.mp3`,
|
|
62
63
|
* so a person sends the file and the findings deliberately, to exactly who they
|
|
63
64
|
* meant. A shareable key would trade a deliberate send for an accidental one, on
|
|
64
|
-
* the most sensitive input any template here accepts.
|
|
65
|
+
* the most sensitive input any template here accepts. The default key stays in
|
|
65
66
|
* `sessionStorage`, which covers the reload this section is about and dies with
|
|
66
67
|
* the tab.
|
|
67
68
|
*
|
|
@@ -75,18 +76,18 @@ import "@alexkroman1/aai-ui/styles.css";
|
|
|
75
76
|
// already declares.
|
|
76
77
|
import { formatBytes, formatDuration } from "@alexkroman1/aai/utils";
|
|
77
78
|
import {
|
|
79
|
+
BulletList,
|
|
78
80
|
createWorkflowApi,
|
|
81
|
+
Facts,
|
|
79
82
|
Form,
|
|
80
|
-
|
|
83
|
+
mountPage,
|
|
81
84
|
SubmitButton,
|
|
82
85
|
UploadProgressBar,
|
|
83
86
|
useDownloadUrl,
|
|
84
|
-
useRunKey,
|
|
85
87
|
useWorkflowSubmit,
|
|
86
88
|
WorkflowFields,
|
|
87
89
|
WorkflowProgress,
|
|
88
90
|
} from "@alexkroman1/aai-ui";
|
|
89
|
-
import { useState } from "react";
|
|
90
91
|
import type { audit } from "./agent.ts";
|
|
91
92
|
|
|
92
93
|
/**
|
|
@@ -130,36 +131,13 @@ function Stat({ label, value }: { label: string; value: string }) {
|
|
|
130
131
|
);
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
/** A list that renders nothing rather than an empty box — see `risks` in the schema. */
|
|
134
|
-
function Findings({ title, items }: { title: string; items: string[] }) {
|
|
135
|
-
if (items.length === 0) return null;
|
|
136
|
-
return (
|
|
137
|
-
<section className="flex flex-col gap-1">
|
|
138
|
-
<h3 className="text-sm font-medium opacity-70">{title}</h3>
|
|
139
|
-
<ul className="flex list-disc flex-col gap-1 pl-5">
|
|
140
|
-
{items.map((item) => (
|
|
141
|
-
<li key={item}>{item}</li>
|
|
142
|
-
))}
|
|
143
|
-
</ul>
|
|
144
|
-
</section>
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
134
|
export function App() {
|
|
149
|
-
// This tab's handle on its own audits, in the store the upload recall uses —
|
|
150
|
-
// see the module doc for why a `?key=` is the wrong trade on this desk.
|
|
151
|
-
const key = useRunKey();
|
|
152
|
-
// Did THIS load start the run? A reload cannot have, and that is the only way
|
|
153
|
-
// the page can tell "auditing what you just uploaded" from "picking up where
|
|
154
|
-
// you left off" — the hook reports the run, not who asked for it.
|
|
155
|
-
const [startedHere, setStartedHere] = useState(false);
|
|
156
135
|
// The generic is what makes `run.status === "completed"` narrow to a TYPED
|
|
157
|
-
// `run.output` instead of `unknown`.
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error } =
|
|
161
|
-
typeof audit
|
|
162
|
-
>(WORKFLOW, { api, key, recover: true });
|
|
136
|
+
// `run.output` instead of `unknown`. The reload — both halves of it — is the
|
|
137
|
+
// hook's own doing; see the module doc for why the key it mints is the one
|
|
138
|
+
// this desk wants.
|
|
139
|
+
const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error, startedHere } =
|
|
140
|
+
useWorkflowSubmit<typeof audit>(WORKFLOW, { api });
|
|
163
141
|
const output = run?.status === "completed" ? run.output : undefined;
|
|
164
142
|
// `useDownloadUrl` is the SDK's: the byte route takes the agent's bearer, so the
|
|
165
143
|
// bytes have to be FETCHED and handed to the element as an object URL — and the
|
|
@@ -176,14 +154,7 @@ export function App() {
|
|
|
176
154
|
</p>
|
|
177
155
|
</header>
|
|
178
156
|
|
|
179
|
-
<Form
|
|
180
|
-
onSubmit={(values) => {
|
|
181
|
-
setStartedHere(true);
|
|
182
|
-
return submitForm(values);
|
|
183
|
-
}}
|
|
184
|
-
error={error}
|
|
185
|
-
className="flex flex-col gap-4"
|
|
186
|
-
>
|
|
157
|
+
<Form onSubmit={(values) => submitForm(values)} error={error} className="flex flex-col gap-4">
|
|
187
158
|
{/* Every control, from the workflow's own input schema. See the module doc. */}
|
|
188
159
|
<WorkflowFields workflow={WORKFLOW} />
|
|
189
160
|
<SubmitButton pending={pending} pendingLabel="Auditing…">
|
|
@@ -203,20 +174,26 @@ export function App() {
|
|
|
203
174
|
for `<WorkflowProgress>` to read. */}
|
|
204
175
|
<UploadProgressBar upload={upload} onPause={pauseUpload} onResume={resumeUpload} />
|
|
205
176
|
|
|
206
|
-
{/* What the run itself says, from `
|
|
177
|
+
{/* What the run itself says, from `stepReport()` in the workflow's steps — which
|
|
207
178
|
for this template is the ffmpeg narration: what the file was, what it
|
|
208
179
|
measured, how many pauses were found. */}
|
|
209
180
|
<WorkflowProgress runId={run?.runId} api={api} />
|
|
210
181
|
|
|
211
|
-
{
|
|
182
|
+
{/* `role="alert"`, the same contract `<Form>` gives the submit error: this
|
|
183
|
+
is the outcome the reader waited minutes for. */}
|
|
184
|
+
{run?.status === "failed" && (
|
|
185
|
+
<p role="alert" className="text-red-600">
|
|
186
|
+
That one failed: {run.error}
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
212
189
|
|
|
213
190
|
{output !== undefined && (
|
|
214
191
|
<article className="flex flex-col gap-6">
|
|
215
192
|
<div className="flex flex-col gap-1">
|
|
216
193
|
<h2 className="text-xl">{output.headline}</h2>
|
|
217
|
-
<
|
|
218
|
-
{output.source
|
|
219
|
-
|
|
194
|
+
<Facts
|
|
195
|
+
items={[output.source, formatDuration(output.durationMs), `${output.words} words`]}
|
|
196
|
+
/>
|
|
220
197
|
</div>
|
|
221
198
|
|
|
222
199
|
{/* What the pipeline did, which is this template's subject. Rendered rather
|
|
@@ -234,8 +211,11 @@ export function App() {
|
|
|
234
211
|
<Stat label="Run time" value={formatDuration(output.elapsedMs)} />
|
|
235
212
|
</dl>
|
|
236
213
|
|
|
237
|
-
|
|
238
|
-
|
|
214
|
+
{/* Either list can come back empty — see `risks` in the schema — and
|
|
215
|
+
`<BulletList>` renders nothing at all when it does, heading
|
|
216
|
+
included, rather than a stray heading over an empty box. */}
|
|
217
|
+
<BulletList title="Risks" items={output.risks} />
|
|
218
|
+
<BulletList title="Actions" items={output.actions} />
|
|
239
219
|
|
|
240
220
|
<section className="flex flex-col gap-2">
|
|
241
221
|
<h3 className="text-sm font-medium opacity-70">
|
|
@@ -244,7 +224,9 @@ export function App() {
|
|
|
244
224
|
</h3>
|
|
245
225
|
{audio.pending && <p className="text-sm opacity-70">Fetching the audio…</p>}
|
|
246
226
|
{audio.error !== undefined && (
|
|
247
|
-
<p className="text-red-600">
|
|
227
|
+
<p role="alert" className="text-red-600">
|
|
228
|
+
Could not load the audio: {audio.error}
|
|
229
|
+
</p>
|
|
248
230
|
)}
|
|
249
231
|
{audio.url !== undefined && (
|
|
250
232
|
<>
|
|
@@ -254,7 +236,7 @@ export function App() {
|
|
|
254
236
|
track would carry. `spoken-summary` serves a one-cue WebVTT
|
|
255
237
|
data URL instead — worth reading for how, if a real track is
|
|
256
238
|
what a page needs. */}
|
|
257
|
-
<audio controls src={audio.url} className="w-full" />
|
|
239
|
+
<audio aria-label="Audit read aloud" controls src={audio.url} className="w-full" />
|
|
258
240
|
{/* `download` works on an object URL because the bytes are already in
|
|
259
241
|
the tab; it is the href that could not carry the agent's bearer,
|
|
260
242
|
not the attribute. */}
|
|
@@ -276,4 +258,4 @@ export function App() {
|
|
|
276
258
|
);
|
|
277
259
|
}
|
|
278
260
|
|
|
279
|
-
|
|
261
|
+
mountPage({ name: "Call Audit", component: App });
|