wicked-crew 0.7.46 → 0.7.47
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/api/capture.d.ts +2 -2
- package/dist/api/delivery-index.d.ts +21 -0
- package/dist/api/delivery-index.d.ts.map +1 -1
- package/dist/api/delivery-index.js +36 -0
- package/dist/api/delivery-index.js.map +1 -1
- package/dist/api/elicitation-cache.d.ts +12 -0
- package/dist/api/elicitation-cache.d.ts.map +1 -1
- package/dist/api/elicitation-cache.js +17 -1
- package/dist/api/elicitation-cache.js.map +1 -1
- package/dist/api/eval-sample.d.ts +6 -6
- package/dist/api/governance-steering.d.ts.map +1 -1
- package/dist/api/governance-steering.js +1 -0
- package/dist/api/governance-steering.js.map +1 -1
- package/dist/api/mcp.d.ts +4 -4
- package/dist/api/policy-landing.d.ts +55 -0
- package/dist/api/policy-landing.d.ts.map +1 -0
- package/dist/api/policy-landing.js +174 -0
- package/dist/api/policy-landing.js.map +1 -0
- package/dist/api/recording.d.ts +228 -0
- package/dist/api/recording.d.ts.map +1 -0
- package/dist/api/recording.js +1140 -0
- package/dist/api/recording.js.map +1 -0
- package/dist/api/routes.d.ts +31 -39
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +264 -104
- package/dist/api/routes.js.map +1 -1
- package/dist/api/run-timing-index.d.ts +17 -0
- package/dist/api/run-timing-index.d.ts.map +1 -1
- package/dist/api/run-timing-index.js +43 -1
- package/dist/api/run-timing-index.js.map +1 -1
- package/dist/api/server.d.ts +12 -0
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +74 -1
- package/dist/api/server.js.map +1 -1
- package/dist/api/testing.d.ts +4 -4
- package/dist/api/testing.d.ts.map +1 -1
- package/dist/api/testing.js +3 -0
- package/dist/api/testing.js.map +1 -1
- package/dist/api/watch-routes.d.ts +30 -0
- package/dist/api/watch-routes.d.ts.map +1 -0
- package/dist/api/watch-routes.js +104 -0
- package/dist/api/watch-routes.js.map +1 -0
- package/dist/campaigns/routes.d.ts +2 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/core/adapter.d.ts +36 -0
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +177 -5
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/run-identity.js +1 -1
- package/dist/core/run-identity.js.map +1 -1
- package/dist/core/walkthrough-root.d.ts +21 -0
- package/dist/core/walkthrough-root.d.ts.map +1 -0
- package/dist/core/walkthrough-root.js +66 -0
- package/dist/core/walkthrough-root.js.map +1 -0
- package/dist/decisions/derive.d.ts +79 -0
- package/dist/decisions/derive.d.ts.map +1 -0
- package/dist/decisions/derive.js +282 -0
- package/dist/decisions/derive.js.map +1 -0
- package/dist/decisions/events.d.ts +23 -0
- package/dist/decisions/events.d.ts.map +1 -0
- package/dist/decisions/events.js +22 -0
- package/dist/decisions/events.js.map +1 -0
- package/dist/decisions/ingest.d.ts +28 -0
- package/dist/decisions/ingest.d.ts.map +1 -0
- package/dist/decisions/ingest.js +92 -0
- package/dist/decisions/ingest.js.map +1 -0
- package/dist/decisions/land.d.ts +100 -0
- package/dist/decisions/land.d.ts.map +1 -0
- package/dist/decisions/land.js +466 -0
- package/dist/decisions/land.js.map +1 -0
- package/dist/decisions/ledger.d.ts +60 -0
- package/dist/decisions/ledger.d.ts.map +1 -0
- package/dist/decisions/ledger.js +246 -0
- package/dist/decisions/ledger.js.map +1 -0
- package/dist/decisions/routes.d.ts +10 -0
- package/dist/decisions/routes.d.ts.map +1 -0
- package/dist/decisions/routes.js +141 -0
- package/dist/decisions/routes.js.map +1 -0
- package/dist/decisions/types.d.ts +120 -0
- package/dist/decisions/types.d.ts.map +1 -0
- package/dist/decisions/types.js +19 -0
- package/dist/decisions/types.js.map +1 -0
- package/dist/interactive/draft-events.d.ts.map +1 -1
- package/dist/interactive/draft-events.js +1 -0
- package/dist/interactive/draft-events.js.map +1 -1
- package/dist/projects/events.d.ts.map +1 -1
- package/dist/projects/events.js +2 -1
- package/dist/projects/events.js.map +1 -1
- package/dist/projects/state-home-preflight.d.ts.map +1 -1
- package/dist/projects/state-home-preflight.js +3 -0
- package/dist/projects/state-home-preflight.js.map +1 -1
- package/dist/projects/state-home-registry.d.ts.map +1 -1
- package/dist/projects/state-home-registry.js +2 -0
- package/dist/projects/state-home-registry.js.map +1 -1
- package/dist/projects/state-home.d.ts +6 -0
- package/dist/projects/state-home.d.ts.map +1 -1
- package/dist/projects/state-home.js +28 -1
- package/dist/projects/state-home.js.map +1 -1
- package/dist/qe/acceptance.d.ts +45 -4
- package/dist/qe/acceptance.d.ts.map +1 -1
- package/dist/qe/acceptance.js +93 -4
- package/dist/qe/acceptance.js.map +1 -1
- package/dist/qe/ledger.d.ts +20 -0
- package/dist/qe/ledger.d.ts.map +1 -1
- package/dist/qe/ledger.js +34 -0
- package/dist/qe/ledger.js.map +1 -1
- package/dist/qe/walkthrough-acceptance.d.ts +82 -0
- package/dist/qe/walkthrough-acceptance.d.ts.map +1 -0
- package/dist/qe/walkthrough-acceptance.js +248 -0
- package/dist/qe/walkthrough-acceptance.js.map +1 -0
- package/dist/skills/base-skill.d.ts.map +1 -1
- package/dist/skills/base-skill.js +3 -2
- package/dist/skills/base-skill.js.map +1 -1
- package/dist/skills/plugin-source.d.ts +19 -1
- package/dist/skills/plugin-source.d.ts.map +1 -1
- package/dist/skills/plugin-source.js +31 -2
- package/dist/skills/plugin-source.js.map +1 -1
- package/dist/skills/runtime.d.ts +11 -2
- package/dist/skills/runtime.d.ts.map +1 -1
- package/dist/skills/runtime.js +29 -1
- package/dist/skills/runtime.js.map +1 -1
- package/dist/skills/store.d.ts +14 -1
- package/dist/skills/store.d.ts.map +1 -1
- package/dist/skills/store.js +19 -5
- package/dist/skills/store.js.map +1 -1
- package/dist/standing-orders/routes.d.ts +4 -4
- package/dist/standing-orders/store.d.ts +2 -2
- package/dist/watch/checks/check-failed.d.ts +24 -0
- package/dist/watch/checks/check-failed.d.ts.map +1 -0
- package/dist/watch/checks/check-failed.js +56 -0
- package/dist/watch/checks/check-failed.js.map +1 -0
- package/dist/watch/checks/deliver-audit.d.ts +19 -0
- package/dist/watch/checks/deliver-audit.d.ts.map +1 -0
- package/dist/watch/checks/deliver-audit.js +129 -0
- package/dist/watch/checks/deliver-audit.js.map +1 -0
- package/dist/watch/checks/index.d.ts +8 -0
- package/dist/watch/checks/index.d.ts.map +1 -0
- package/dist/watch/checks/index.js +19 -0
- package/dist/watch/checks/index.js.map +1 -0
- package/dist/watch/checks/lag.d.ts +24 -0
- package/dist/watch/checks/lag.d.ts.map +1 -0
- package/dist/watch/checks/lag.js +55 -0
- package/dist/watch/checks/lag.js.map +1 -0
- package/dist/watch/checks/quiet-after-claim.d.ts +17 -0
- package/dist/watch/checks/quiet-after-claim.d.ts.map +1 -0
- package/dist/watch/checks/quiet-after-claim.js +89 -0
- package/dist/watch/checks/quiet-after-claim.js.map +1 -0
- package/dist/watch/checks/ungated.d.ts +12 -0
- package/dist/watch/checks/ungated.d.ts.map +1 -0
- package/dist/watch/checks/ungated.js +64 -0
- package/dist/watch/checks/ungated.js.map +1 -0
- package/dist/watch/emit.d.ts +122 -0
- package/dist/watch/emit.d.ts.map +1 -0
- package/dist/watch/emit.js +443 -0
- package/dist/watch/emit.js.map +1 -0
- package/dist/watch/events.d.ts +21 -0
- package/dist/watch/events.d.ts.map +1 -0
- package/dist/watch/events.js +21 -0
- package/dist/watch/events.js.map +1 -0
- package/dist/watch/keys.d.ts +15 -0
- package/dist/watch/keys.d.ts.map +1 -0
- package/dist/watch/keys.js +23 -0
- package/dist/watch/keys.js.map +1 -0
- package/dist/watch/lanes.d.ts +41 -0
- package/dist/watch/lanes.d.ts.map +1 -0
- package/dist/watch/lanes.js +73 -0
- package/dist/watch/lanes.js.map +1 -0
- package/dist/watch/loader.d.ts +47 -0
- package/dist/watch/loader.d.ts.map +1 -0
- package/dist/watch/loader.js +178 -0
- package/dist/watch/loader.js.map +1 -0
- package/dist/watch/registry.d.ts +137 -0
- package/dist/watch/registry.d.ts.map +1 -0
- package/dist/watch/registry.js +530 -0
- package/dist/watch/registry.js.map +1 -0
- package/dist/watch/ring.d.ts +32 -0
- package/dist/watch/ring.d.ts.map +1 -0
- package/dist/watch/ring.js +90 -0
- package/dist/watch/ring.js.map +1 -0
- package/dist/watch/router.d.ts +25 -0
- package/dist/watch/router.d.ts.map +1 -0
- package/dist/watch/router.js +87 -0
- package/dist/watch/router.js.map +1 -0
- package/dist/watch/sources.d.ts +42 -0
- package/dist/watch/sources.d.ts.map +1 -0
- package/dist/watch/sources.js +104 -0
- package/dist/watch/sources.js.map +1 -0
- package/dist/watch/types.d.ts +110 -0
- package/dist/watch/types.d.ts.map +1 -0
- package/dist/watch/types.js +6 -0
- package/dist/watch/types.js.map +1 -0
- package/endpoint-manifest.json +178 -1
- package/package.json +3 -2
- package/watch/entries/deliver-audit.json +17 -0
- package/watch/entries/quiet-after-claim.json +16 -0
- package/watch/entries/registry-check-failed.json +12 -0
- package/watch/entries/registry-lagging.json +12 -0
- package/watch/entries/ungated.json +13 -0
- package/dist/api/demo.d.ts +0 -90
- package/dist/api/demo.d.ts.map +0 -1
- package/dist/api/demo.js +0 -492
- package/dist/api/demo.js.map +0 -1
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recording views (DES-walkthrough-proof §4.10: `api/demo.ts` renamed `api/recording.ts`) — the
|
|
3
|
+
* Demo experience and the walkthrough, which share `parseChapters`, `parseMarkers` and
|
|
4
|
+
* `containedPath`.
|
|
5
|
+
*
|
|
6
|
+
* ## Demo
|
|
7
|
+
*
|
|
8
|
+
* The Demo experience (wicked-studio#373, migration M9b): a demo of a local app, made by a governed
|
|
9
|
+
* run of the built-in `demo` preset — the wicked-garden demo skill's plan → record → review.
|
|
10
|
+
*
|
|
11
|
+
* POST /projects/:id/demo mints the run's DEMO ROOT (a per-run directory on the daemon host),
|
|
12
|
+
* writes the brief there, and launches `demo` THROUGH `POST /runs`
|
|
13
|
+
* (one launch path), with the root as the run's only extra write root.
|
|
14
|
+
* GET /runs/:id/demo what studio shows: the stage, the presenter script and chapter list
|
|
15
|
+
* (the plan gate), per-chapter record progress, the contact sheets and
|
|
16
|
+
* the reviewer's verdicts (the review gate), and the chaptered MP4.
|
|
17
|
+
* GET /runs/:id/demo/file one file of the root (a contact sheet, the MP4 — with Range).
|
|
18
|
+
* PUT /runs/:id/demo/script the presenter's edit of `script.md` while the plan gate is open.
|
|
19
|
+
* POST /runs/:id/demo/export a GIF (`demo-video/demo.gif`) or a poster frame (`demo-video/poster.jpg`)
|
|
20
|
+
* of the stitched MP4, encoded by ffmpeg with an ASYNC spawn and a
|
|
21
|
+
* timeout (EP-C3): the daemon keeps answering while it encodes.
|
|
22
|
+
*
|
|
23
|
+
* The gates are the run's ordinary gates (`POST /runs/:id/gate`): approve, or `request_changes` with a
|
|
24
|
+
* note — at the plan gate it re-runs `plan`, at the review gate it rewinds to `record` (re-record one
|
|
25
|
+
* chapter). Nothing here decides a gate.
|
|
26
|
+
*
|
|
27
|
+
* Every file is read from the root the daemon minted for THIS run id, never from a path a caller or a
|
|
28
|
+
* worker names: `path` is resolved inside it, symlinks included, and only the deliverable types studio
|
|
29
|
+
* renders are served.
|
|
30
|
+
*
|
|
31
|
+
* ## Walkthrough (WT-W1)
|
|
32
|
+
*
|
|
33
|
+
* Every repo-bound run gets an EVIDENCE ROOT (`walkthroughRootDir`, minted by `CoreAdapter.launchRun`
|
|
34
|
+
* and persisted by the engine as `session.evidence_root`). Under it, `author/<plan step>/` is the
|
|
35
|
+
* walkthrough author's write dir and `<review step>/` is the PROOF ROOT only the jailed
|
|
36
|
+
* `walkthrough_review` Tool writes.
|
|
37
|
+
*
|
|
38
|
+
* GET /runs/:id/walkthrough?step= the `WalkthroughView` of the newest (or the named) pair.
|
|
39
|
+
* GET /runs/:id/walkthrough/file?step=&path= one file of that pair's proof root (Range for the MP4).
|
|
40
|
+
*
|
|
41
|
+
* What the view reads from a proof root (the recorder's files, DES-walkthrough-proof §4.4):
|
|
42
|
+
*
|
|
43
|
+
* result.json `{overall, cause?, reason?, tree?, chapters: [{key, title?, verdict, takes?,
|
|
44
|
+
* failed_at_sec?, failed_frame?, proves?, legs?, checks?: [{id, kind, sentence,
|
|
45
|
+
* passed, at_sec, evidence?, vault_entry?, detail?}]}]}` — the engine itself writes
|
|
46
|
+
* `{overall: INCONCLUSIVE, cause, reason, chapters: []}` when it cannot record.
|
|
47
|
+
* progress.json `{state: 'starting_app' | 'recording' | 'judging', chapter?}` while it runs.
|
|
48
|
+
* chapters.json the chapter list (the demo shape), before the judge has written verdicts.
|
|
49
|
+
* demo-video/ `demo.mp4`, `poster.jpg`, `chapters.md` (markers), `segments/<key>/segment.mp4`.
|
|
50
|
+
*
|
|
51
|
+
* The proof root is read only when it is a plain directory directly under the evidence root (a link
|
|
52
|
+
* planted at the step path is never followed — the engine's `check_proof_root` rule), and every file
|
|
53
|
+
* inside it through `containedPath`.
|
|
54
|
+
*/
|
|
55
|
+
import { spawn } from 'node:child_process';
|
|
56
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
57
|
+
import { constants as fsConstants, createReadStream, promises as fsp } from 'node:fs';
|
|
58
|
+
import { tmpdir } from 'node:os';
|
|
59
|
+
import { extname, join, relative, resolve, sep } from 'node:path';
|
|
60
|
+
import { z } from 'zod';
|
|
61
|
+
import { childEnvWithBootEstateDb } from '../core/governance-store.js';
|
|
62
|
+
import { API_PREFIX } from './api-prefix.js';
|
|
63
|
+
import { coreUnitId } from './evidence.js';
|
|
64
|
+
import { stepIdOf, WALKTHROUGH_AUTHOR_SUBDIR, walkthroughProofRoot } from '../core/walkthrough-root.js';
|
|
65
|
+
import { LOCAL_ACTOR } from './auth.js';
|
|
66
|
+
import { resolveWalkthroughGate, walkthroughCheckStates } from '../qe/walkthrough-acceptance.js';
|
|
67
|
+
const V = API_PREFIX;
|
|
68
|
+
/** The preset a demo run launches (wicked-core `builtin_presets`). */
|
|
69
|
+
export const DEMO_PRESET = 'demo';
|
|
70
|
+
/** Studio's Unfiled mount (`/p/default/…`): the daemon's synthesized project, never a launch's `projectId`. */
|
|
71
|
+
const UNFILED_PROJECT = 'default';
|
|
72
|
+
/** The biggest script studio shows or accepts back: a presenter script is prose. */
|
|
73
|
+
export const DEMO_SCRIPT_MAX_BYTES = 256 * 1024;
|
|
74
|
+
/** What `/demo/file` serves, by extension — the deliverables studio renders, nothing else. */
|
|
75
|
+
const DEMO_FILE_TYPES = {
|
|
76
|
+
'.mp4': 'video/mp4',
|
|
77
|
+
'.png': 'image/png',
|
|
78
|
+
'.jpg': 'image/jpeg',
|
|
79
|
+
'.gif': 'image/gif',
|
|
80
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
81
|
+
'.json': 'application/json; charset=utf-8',
|
|
82
|
+
};
|
|
83
|
+
/** Where a demo run's files live: one directory per run, outside every sandbox and state home. */
|
|
84
|
+
export function demoRootDir(runId) {
|
|
85
|
+
if (process.env.WICKED_DEMO_DIR)
|
|
86
|
+
return join(process.env.WICKED_DEMO_DIR, runId);
|
|
87
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? '/tmp';
|
|
88
|
+
return join(home, '.wicked', 'demos', runId);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The demo root of each demo launch in flight, keyed by the run id the demo route minted. `POST /runs`
|
|
92
|
+
* takes no write roots from its body; it reads this in-process entry, so the root is never something a
|
|
93
|
+
* caller can ask for (the capture route's pattern).
|
|
94
|
+
*/
|
|
95
|
+
export const demoLaunchRoots = new Map();
|
|
96
|
+
export const DemoLaunchSchema = z
|
|
97
|
+
.object({
|
|
98
|
+
url: z.string().trim().url().max(2048).refine((u) => /^https?:\/\//i.test(u), 'url must be http(s)'),
|
|
99
|
+
audience: z.string().trim().min(1).max(2000),
|
|
100
|
+
show: z.string().trim().min(1).max(4000),
|
|
101
|
+
clisJson: z.string().optional(),
|
|
102
|
+
})
|
|
103
|
+
.strict();
|
|
104
|
+
export const DemoScriptSchema = z.object({ content: z.string().min(1) }).strict();
|
|
105
|
+
export const DemoExportSchema = z
|
|
106
|
+
.object({
|
|
107
|
+
format: z.enum(['gif', 'poster']),
|
|
108
|
+
/** The poster's frame, in seconds from the start. Poster only. */
|
|
109
|
+
atSec: z.number().finite().min(0).max(24 * 60 * 60).optional(),
|
|
110
|
+
})
|
|
111
|
+
.strict()
|
|
112
|
+
.refine((b) => b.format === 'poster' || b.atSec === undefined, { message: '`atSec` applies to a poster only', path: ['atSec'] });
|
|
113
|
+
/**
|
|
114
|
+
* How long one encode may run before it is killed (EP-C3). A GIF re-encodes the whole video (the bound
|
|
115
|
+
* interactive's encoder uses); a poster decodes one frame. Mutable only so a test can shorten it.
|
|
116
|
+
*/
|
|
117
|
+
export const DEMO_EXPORT_TIMEOUT_MS = { gif: 180_000, poster: 30_000 };
|
|
118
|
+
/** Interactive's GIF filter (`src/service/demo.js` `ffmpegGifEncoder`): two-pass palette so colours do not band, 10 fps, 720 px. */
|
|
119
|
+
export const DEMO_GIF_FILTER = 'fps=10,scale=720:-1:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3';
|
|
120
|
+
/** The stages an export may run in: no worker of the run is running, so none can write the root. */
|
|
121
|
+
const DEMO_EXPORT_STAGES = new Set(['review_gate', 'done', 'failed']);
|
|
122
|
+
/** What each export writes, inside the run's `demo-video/`. */
|
|
123
|
+
const DEMO_EXPORT_FILE = { gif: 'demo.gif', poster: 'poster.jpg' };
|
|
124
|
+
export const DEMO_FFMPEG_HINT = 'install ffmpeg (e.g. `brew install ffmpeg` / `apt install ffmpeg`) on the daemon host, or set WICKED_FFMPEG to its path';
|
|
125
|
+
/**
|
|
126
|
+
* Run ffmpeg without blocking the event loop: an async `spawn`, its stderr tail kept for the error,
|
|
127
|
+
* SIGKILL past `timeoutMs`. Never `spawnSync` (review N6: a 180 s synchronous encode would stall
|
|
128
|
+
* every route the daemon serves, `/health` included).
|
|
129
|
+
*/
|
|
130
|
+
export function runFfmpeg(args, timeoutMs) {
|
|
131
|
+
const bin = process.env.WICKED_FFMPEG || 'ffmpeg';
|
|
132
|
+
return new Promise((resolveOutcome) => {
|
|
133
|
+
let settled = false;
|
|
134
|
+
const settle = (o) => {
|
|
135
|
+
if (settled)
|
|
136
|
+
return;
|
|
137
|
+
settled = true;
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
resolveOutcome(o);
|
|
140
|
+
};
|
|
141
|
+
let stderr = '';
|
|
142
|
+
// crew#495: no child of the daemon inherits a daemon-exported governance store.
|
|
143
|
+
const child = spawn(bin, args, {
|
|
144
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
145
|
+
windowsHide: true,
|
|
146
|
+
env: childEnvWithBootEstateDb(process.env),
|
|
147
|
+
});
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
child.kill('SIGKILL');
|
|
150
|
+
settle({ kind: 'timeout' });
|
|
151
|
+
}, timeoutMs);
|
|
152
|
+
child.stderr?.on('data', (chunk) => {
|
|
153
|
+
stderr = (stderr + chunk.toString('utf8')).slice(-2000);
|
|
154
|
+
});
|
|
155
|
+
child.on('error', (err) => {
|
|
156
|
+
settle(err.code === 'ENOENT' || err.code === 'EACCES' ? { kind: 'missing' } : { kind: 'failed', code: null, stderr: err.message });
|
|
157
|
+
});
|
|
158
|
+
child.on('close', (code) => {
|
|
159
|
+
settle(code === 0 ? { kind: 'ok' } : { kind: 'failed', code, stderr });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Encode one export of a run's stitched MP4. The run's workers can write its demo root, so ffmpeg never
|
|
165
|
+
* touches it: the MP4 is copied into a daemon-private staging directory, encoded there, and only the
|
|
166
|
+
* finished file is published back, after `stillExportable` confirms the run has not resumed (a review
|
|
167
|
+
* gate answered with `request_changes` during the encode re-opens `record`) and `demo-video/` still
|
|
168
|
+
* resolves where it did. Publication is an exclusive copy to a fresh name inside `demo-video/` and a
|
|
169
|
+
* rename, so a half-written export is never served and a failed or killed encode leaves nothing behind.
|
|
170
|
+
*/
|
|
171
|
+
async function encodeDemoExport(runId, format, atSec, stillExportable) {
|
|
172
|
+
const root = demoRootDir(runId);
|
|
173
|
+
const input = await containedPath(root, 'demo-video/demo.mp4');
|
|
174
|
+
const inputSize = input === null ? null : await fsp.stat(input).then((st) => (st.isFile() ? st.size : null), () => null);
|
|
175
|
+
if (input === null || inputSize === null || inputSize === 0) {
|
|
176
|
+
return { status: 409, body: { error: 'this demo has no stitched demo-video/demo.mp4 yet' } };
|
|
177
|
+
}
|
|
178
|
+
// The output directory is resolved on its own, not from the input: an in-root symlink
|
|
179
|
+
// `demo-video/demo.mp4 -> ../elsewhere/x.mp4` must not move the export away from the path served.
|
|
180
|
+
const outDir = await containedPath(root, 'demo-video');
|
|
181
|
+
if (outDir === null)
|
|
182
|
+
return { status: 409, body: { error: 'this demo has no demo-video/ directory' } };
|
|
183
|
+
const name = DEMO_EXPORT_FILE[format];
|
|
184
|
+
const ext = extname(name);
|
|
185
|
+
const staging = await fsp.mkdtemp(join(tmpdir(), 'wicked-crew-demo-export-'));
|
|
186
|
+
const source = join(staging, 'source.mp4');
|
|
187
|
+
const encoded = join(staging, `export${ext}`);
|
|
188
|
+
let published = null;
|
|
189
|
+
try {
|
|
190
|
+
await fsp.copyFile(input, source);
|
|
191
|
+
// The input is read as an MP4 and nothing else, from the local file alone: a worker-written
|
|
192
|
+
// `demo.mp4` that is really a playlist (HLS, concat) naming media outside the root is refused by the
|
|
193
|
+
// demuxer instead of followed with the daemon's permissions.
|
|
194
|
+
const demux = ['-f', 'mov', '-protocol_whitelist', 'file', '-i', source];
|
|
195
|
+
let args;
|
|
196
|
+
let at;
|
|
197
|
+
if (format === 'gif') {
|
|
198
|
+
args = ['-y', ...demux, '-vf', DEMO_GIF_FILTER, '-loop', '0', encoded];
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Default: one second into the first chapter (the title card is past), or 1 s with no markers.
|
|
202
|
+
at = atSec ?? (parseMarkers(await readText(root, 'demo-video/chapters.md'))[0]?.sec ?? 0) + 1;
|
|
203
|
+
args = ['-y', '-ss', String(at), ...demux, '-frames:v', '1', '-q:v', '3', '-update', '1', encoded];
|
|
204
|
+
}
|
|
205
|
+
const outcome = await runFfmpeg(args, DEMO_EXPORT_TIMEOUT_MS[format]);
|
|
206
|
+
if (outcome.kind === 'missing') {
|
|
207
|
+
return { status: 503, body: { error: 'ffmpeg not found on the daemon host', hint: DEMO_FFMPEG_HINT } };
|
|
208
|
+
}
|
|
209
|
+
if (outcome.kind === 'timeout') {
|
|
210
|
+
return { status: 504, body: { error: `the ${format} encode ran past ${DEMO_EXPORT_TIMEOUT_MS[format] / 1000} s and was stopped` } };
|
|
211
|
+
}
|
|
212
|
+
if (outcome.kind === 'failed') {
|
|
213
|
+
return { status: 502, body: { error: `ffmpeg failed (exit ${outcome.code}): ${outcome.stderr.trim().slice(-300)}` } };
|
|
214
|
+
}
|
|
215
|
+
const bytes = await fsp.stat(encoded).then((st) => (st.isFile() ? st.size : 0), () => 0);
|
|
216
|
+
if (bytes === 0) {
|
|
217
|
+
return {
|
|
218
|
+
status: 422,
|
|
219
|
+
body: { error: at !== undefined ? `no frame at ${at} s: the video is shorter` : `ffmpeg wrote no ${format}` },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (!(await stillExportable())) {
|
|
223
|
+
return { status: 409, body: { error: 'the run resumed during the encode; the export was discarded' } };
|
|
224
|
+
}
|
|
225
|
+
if ((await containedPath(root, 'demo-video')) !== outDir) {
|
|
226
|
+
return { status: 409, body: { error: 'demo-video/ changed during the encode; nothing was written' } };
|
|
227
|
+
}
|
|
228
|
+
published = join(outDir, `.export-${randomUUID()}${ext}`);
|
|
229
|
+
await fsp.copyFile(encoded, published, fsConstants.COPYFILE_EXCL);
|
|
230
|
+
await fsp.rename(published, join(outDir, name));
|
|
231
|
+
published = null;
|
|
232
|
+
return { status: 200, body: { format, path: `demo-video/${name}`, bytes } };
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
if (published !== null)
|
|
236
|
+
await fsp.rm(published, { force: true });
|
|
237
|
+
await fsp.rm(staging, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** The brief the demo run reads (the problem statement stays one short line: worker prompts are capped). */
|
|
241
|
+
export function demoBrief(root, b) {
|
|
242
|
+
return [
|
|
243
|
+
'# Demo brief',
|
|
244
|
+
'',
|
|
245
|
+
`- **App:** ${b.url}`,
|
|
246
|
+
`- **Demo root:** ${root} (every file this demo makes goes here, nowhere else)`,
|
|
247
|
+
'',
|
|
248
|
+
'## Who it is for',
|
|
249
|
+
'',
|
|
250
|
+
b.audience,
|
|
251
|
+
'',
|
|
252
|
+
'## What to show',
|
|
253
|
+
'',
|
|
254
|
+
b.show,
|
|
255
|
+
'',
|
|
256
|
+
'## Rules',
|
|
257
|
+
'',
|
|
258
|
+
'- Use the wicked-garden-demo skill: plan, then record, then review.',
|
|
259
|
+
'- Read-only against the app: navigate, hover, scroll, open panels and type, but never submit,',
|
|
260
|
+
' launch, approve, delete, create, cancel or save. The recorder blocks every write and fails the',
|
|
261
|
+
' chapter; show a control without pressing it.',
|
|
262
|
+
'- Label synthetic data and simulated systems as such, in the script and on screen.',
|
|
263
|
+
'- Measured numbers are timed on this app; anything else is labelled an estimate.',
|
|
264
|
+
'',
|
|
265
|
+
].join('\n');
|
|
266
|
+
}
|
|
267
|
+
/** A demo run: launched from the `demo` preset — read off the engine's plan state, or, before the
|
|
268
|
+
* launch is scored and `team_plan` exists, off the run's identity (resolved from the launch record). */
|
|
269
|
+
export function isDemoRun(view) {
|
|
270
|
+
if (view.session.team_plan?.preset === DEMO_PRESET)
|
|
271
|
+
return true;
|
|
272
|
+
const id = view.session.run_identity;
|
|
273
|
+
return id?.kind === 'preset' && id.name === DEMO_PRESET;
|
|
274
|
+
}
|
|
275
|
+
/** The unit for one of the preset's steps (`<run>:plan` …). */
|
|
276
|
+
function stepUnit(view, step) {
|
|
277
|
+
return view.units.find((u) => u.id === `${view.session.id}:${step}`);
|
|
278
|
+
}
|
|
279
|
+
/** Where the run stands, in the Demo experience's words. */
|
|
280
|
+
export function demoStage(view) {
|
|
281
|
+
const s = view.session;
|
|
282
|
+
if (s.status === 'completed')
|
|
283
|
+
return 'done';
|
|
284
|
+
if (s.status === 'failed' || s.status === 'cancelled')
|
|
285
|
+
return 'failed';
|
|
286
|
+
const cursor = view.units[s.unit_ix];
|
|
287
|
+
const step = cursor?.id.startsWith(`${s.id}:`) ? cursor.id.slice(s.id.length + 1) : undefined;
|
|
288
|
+
const waiting = s.status === 'awaiting_human';
|
|
289
|
+
// A held plan (rev past the accepted one) is the team plan's own approval gate (`plan_approval`),
|
|
290
|
+
// which opens before `plan` has run — never the plan gate, whose script does not exist yet.
|
|
291
|
+
const tp = s.team_plan;
|
|
292
|
+
if (waiting && tp != null && tp.rev > tp.accepted_rev)
|
|
293
|
+
return 'team_gate';
|
|
294
|
+
if (waiting) {
|
|
295
|
+
// A step's human gate opens once that step is done, with the cursor already on the NEXT unit (the
|
|
296
|
+
// engine's gate reviews unit N from ord N+1): the plan gate sits before `record`, the review gate
|
|
297
|
+
// after `review`. Read which step is done, not where the cursor is.
|
|
298
|
+
const done = (id) => stepUnit(view, id)?.status === 'done';
|
|
299
|
+
// After `review`: its own gate when it passed, the engine's escalation gate when it did not.
|
|
300
|
+
if (done('review') || stepUnit(view, 'review')?.status === 'rejected')
|
|
301
|
+
return 'review_gate';
|
|
302
|
+
// Only while `record` has not started: a record the engine rejected parks at ITS escalation gate,
|
|
303
|
+
// which is no plan gate (the script is not editable once the recorder has run).
|
|
304
|
+
const record = stepUnit(view, 'record')?.status;
|
|
305
|
+
if (done('plan') && (record === 'pending' || record === 'distributed'))
|
|
306
|
+
return 'plan_gate';
|
|
307
|
+
}
|
|
308
|
+
if (step === 'plan')
|
|
309
|
+
return 'planning';
|
|
310
|
+
if (step === 'record')
|
|
311
|
+
return 'recording';
|
|
312
|
+
if (step === 'review')
|
|
313
|
+
return 'reviewing';
|
|
314
|
+
// The PA's scope step and any phase floor fill added run before `plan` or between the three.
|
|
315
|
+
const ord = cursor?.ord ?? 0;
|
|
316
|
+
const recordOrd = stepUnit(view, 'record')?.ord;
|
|
317
|
+
const planOrd = stepUnit(view, 'plan')?.ord;
|
|
318
|
+
// While the PA scopes, the run's only unit is `pa-scope`: there is no `plan` unit to compare with.
|
|
319
|
+
if (planOrd === undefined || ord < planOrd)
|
|
320
|
+
return 'preparing';
|
|
321
|
+
if (recordOrd !== undefined && ord > recordOrd)
|
|
322
|
+
return 'reviewing';
|
|
323
|
+
return 'recording';
|
|
324
|
+
}
|
|
325
|
+
/** `chapters.json` → the chapter list, or `[]` when it is missing or not the plan's shape. */
|
|
326
|
+
export function parseChapters(raw) {
|
|
327
|
+
if (raw === null)
|
|
328
|
+
return [];
|
|
329
|
+
let parsed;
|
|
330
|
+
try {
|
|
331
|
+
parsed = JSON.parse(raw);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const list = Array.isArray(parsed)
|
|
337
|
+
? parsed
|
|
338
|
+
: Array.isArray(parsed?.chapters)
|
|
339
|
+
? parsed.chapters
|
|
340
|
+
: [];
|
|
341
|
+
const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const c of list) {
|
|
344
|
+
if (c === null || typeof c !== 'object')
|
|
345
|
+
continue;
|
|
346
|
+
const o = c;
|
|
347
|
+
if (typeof o.key !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(o.key))
|
|
348
|
+
continue;
|
|
349
|
+
out.push({
|
|
350
|
+
key: o.key,
|
|
351
|
+
title: typeof o.title === 'string' ? o.title : o.key,
|
|
352
|
+
blurb: typeof o.blurb === 'string' ? o.blurb : '',
|
|
353
|
+
tags: strs(o.tags),
|
|
354
|
+
resets: strs(o.resets),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
/** `chapters.md` (record.mjs's `| m:ss | Title |` table) → the MP4's chapter markers. */
|
|
360
|
+
export function parseMarkers(raw) {
|
|
361
|
+
if (raw === null)
|
|
362
|
+
return [];
|
|
363
|
+
const out = [];
|
|
364
|
+
for (const line of raw.split('\n')) {
|
|
365
|
+
const m = /^\|\s*(\d+):(\d{2})\s*\|\s*(.+?)\s*\|\s*$/.exec(line);
|
|
366
|
+
if (m === null)
|
|
367
|
+
continue;
|
|
368
|
+
out.push({ at: `${m[1]}:${m[2]}`, sec: Number(m[1]) * 60 + Number(m[2]), title: m[3] });
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
const VERDICTS = new Set(['re-encode', 're-record', 'fix-app']);
|
|
373
|
+
/** The reviewer's output → its verdict block (the LAST fenced ```json block), or nothing. */
|
|
374
|
+
export function parseReview(output) {
|
|
375
|
+
const none = { verdict: null, findings: [] };
|
|
376
|
+
if (output === null)
|
|
377
|
+
return none;
|
|
378
|
+
const blocks = [...output.matchAll(/```json\s*\n([\s\S]*?)```/g)];
|
|
379
|
+
const last = blocks.at(-1)?.[1];
|
|
380
|
+
if (last === undefined)
|
|
381
|
+
return none;
|
|
382
|
+
let parsed;
|
|
383
|
+
try {
|
|
384
|
+
parsed = JSON.parse(last);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return none;
|
|
388
|
+
}
|
|
389
|
+
const o = (parsed ?? {});
|
|
390
|
+
const verdict = o.verdict === 'accept' || o.verdict === 'changes' ? o.verdict : null;
|
|
391
|
+
const findings = [];
|
|
392
|
+
for (const f of Array.isArray(o.findings) ? o.findings : []) {
|
|
393
|
+
if (f === null || typeof f !== 'object')
|
|
394
|
+
continue;
|
|
395
|
+
const r = f;
|
|
396
|
+
if (typeof r.issue !== 'string' || typeof r.verdict !== 'string' || !VERDICTS.has(r.verdict))
|
|
397
|
+
continue;
|
|
398
|
+
findings.push({
|
|
399
|
+
at: typeof r.at === 'string' ? r.at : '',
|
|
400
|
+
chapter: typeof r.chapter === 'string' ? r.chapter : '',
|
|
401
|
+
issue: r.issue,
|
|
402
|
+
verdict: r.verdict,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return { verdict, findings };
|
|
406
|
+
}
|
|
407
|
+
/** A root file's text, resolved inside the root first (a worker-made symlink out of it reads as absent). */
|
|
408
|
+
async function readText(root, rel) {
|
|
409
|
+
const path = await containedPath(root, rel);
|
|
410
|
+
if (path === null)
|
|
411
|
+
return null;
|
|
412
|
+
try {
|
|
413
|
+
const st = await fsp.stat(path);
|
|
414
|
+
if (!st.isFile() || st.size > DEMO_SCRIPT_MAX_BYTES)
|
|
415
|
+
return null;
|
|
416
|
+
return await fsp.readFile(path, 'utf8');
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** A root file's size when it is a regular file inside the root, else null. */
|
|
423
|
+
async function fileSize(root, rel) {
|
|
424
|
+
const path = await containedPath(root, rel);
|
|
425
|
+
if (path === null)
|
|
426
|
+
return null;
|
|
427
|
+
try {
|
|
428
|
+
const st = await fsp.stat(path);
|
|
429
|
+
return st.isFile() ? st.size : null;
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async function isFile(root, rel) {
|
|
436
|
+
return (await fileSize(root, rel)) !== null;
|
|
437
|
+
}
|
|
438
|
+
/** The brief's app URL and audience, for the header (the brief is the daemon's own file). */
|
|
439
|
+
function briefFields(brief) {
|
|
440
|
+
if (brief === null)
|
|
441
|
+
return { url: null, audience: null };
|
|
442
|
+
const url = /^- \*\*App:\*\* (\S+)/m.exec(brief)?.[1] ?? null;
|
|
443
|
+
const audience = /## Who it is for\n\n([\s\S]*?)\n\n## /.exec(brief)?.[1]?.trim() ?? null;
|
|
444
|
+
return { url, audience };
|
|
445
|
+
}
|
|
446
|
+
/** Everything studio renders for a demo run, read from its root and its units. */
|
|
447
|
+
export async function demoView(adapter, view) {
|
|
448
|
+
const runId = view.session.id;
|
|
449
|
+
const root = demoRootDir(runId);
|
|
450
|
+
// Every read is resolved inside the root: the run can write here, so a symlink it made is never followed out.
|
|
451
|
+
const [brief, script, chaptersRaw, markersRaw, recordingRaw] = await Promise.all([
|
|
452
|
+
readText(root, 'BRIEF.md'),
|
|
453
|
+
readText(root, 'script.md'),
|
|
454
|
+
readText(root, 'chapters.json'),
|
|
455
|
+
readText(root, 'demo-video/chapters.md'),
|
|
456
|
+
readText(root, 'demo-video/recording.json'),
|
|
457
|
+
]);
|
|
458
|
+
const chapters = await Promise.all(parseChapters(chaptersRaw).map(async (c) => ({
|
|
459
|
+
...c,
|
|
460
|
+
recorded: await isFile(root, `demo-video/segments/${c.key}/segment.mp4`),
|
|
461
|
+
})));
|
|
462
|
+
const sheets = [];
|
|
463
|
+
for (const name of ['chapters', 'joins', 'end']) {
|
|
464
|
+
if (await isFile(root, `review/${name}.png`))
|
|
465
|
+
sheets.push({ name, path: `review/${name}.png` });
|
|
466
|
+
}
|
|
467
|
+
const videoBytes = await fileSize(root, 'demo-video/demo.mp4');
|
|
468
|
+
const video = videoBytes !== null && videoBytes > 0 ? { path: 'demo-video/demo.mp4', bytes: videoBytes } : null;
|
|
469
|
+
let readOnly = null;
|
|
470
|
+
if (recordingRaw !== null) {
|
|
471
|
+
try {
|
|
472
|
+
const r = JSON.parse(recordingRaw);
|
|
473
|
+
readOnly = typeof r.readOnly === 'boolean' ? r.readOnly : null;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
readOnly = null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const review = stepUnit(view, 'review');
|
|
480
|
+
const record = stepUnit(view, 'record');
|
|
481
|
+
const reviewed = review !== undefined && (review.status === 'done' || review.status === 'rejected');
|
|
482
|
+
const reviewOutput = reviewed ? await adapter.workOutput(coreUnitId(runId, review)) : null;
|
|
483
|
+
const { verdict, findings } = parseReview(reviewOutput);
|
|
484
|
+
return {
|
|
485
|
+
runId,
|
|
486
|
+
...briefFields(brief),
|
|
487
|
+
stage: demoStage(view),
|
|
488
|
+
script,
|
|
489
|
+
chapters,
|
|
490
|
+
markers: parseMarkers(markersRaw),
|
|
491
|
+
sheets,
|
|
492
|
+
video,
|
|
493
|
+
recording: { readOnly },
|
|
494
|
+
review: { verdict, findings, text: reviewOutput, rejected: review?.status === 'rejected' },
|
|
495
|
+
seats: { recorder: record?.assigned_cli ?? null, reviewer: review?.assigned_cli ?? null },
|
|
496
|
+
syntheticLabelled: script !== null && /synthetic/i.test(script),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
/** `path` resolved inside `root` (symlinks included), or `null` when it would leave it. */
|
|
500
|
+
async function containedPath(root, rel) {
|
|
501
|
+
if (rel === '' || rel.includes('\0'))
|
|
502
|
+
return null;
|
|
503
|
+
const target = resolve(root, rel);
|
|
504
|
+
let realRoot;
|
|
505
|
+
let realTarget;
|
|
506
|
+
try {
|
|
507
|
+
realRoot = await fsp.realpath(root);
|
|
508
|
+
realTarget = await fsp.realpath(target);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
const inside = relative(realRoot, realTarget);
|
|
514
|
+
if (inside === '' || inside.startsWith('..') || inside.startsWith(sep) || resolve(realRoot, inside) !== realTarget) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
return realTarget;
|
|
518
|
+
}
|
|
519
|
+
/** A single `Range: bytes=a-b` → the inclusive span, `null` for no header, `'bad'` for one we cannot serve. */
|
|
520
|
+
export function parseRange(header, size) {
|
|
521
|
+
if (header === undefined)
|
|
522
|
+
return null;
|
|
523
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
524
|
+
if (m === null || (m[1] === '' && m[2] === ''))
|
|
525
|
+
return 'bad';
|
|
526
|
+
let start;
|
|
527
|
+
let end;
|
|
528
|
+
if (m[1] === '') {
|
|
529
|
+
const suffix = Number(m[2]);
|
|
530
|
+
if (suffix === 0)
|
|
531
|
+
return 'bad';
|
|
532
|
+
start = Math.max(0, size - suffix);
|
|
533
|
+
end = size - 1;
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
start = Number(m[1]);
|
|
537
|
+
end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
|
|
538
|
+
}
|
|
539
|
+
if (start > end || start >= size)
|
|
540
|
+
return 'bad';
|
|
541
|
+
return { start, end };
|
|
542
|
+
}
|
|
543
|
+
export function registerDemoRoutes(app, adapter) {
|
|
544
|
+
// One encode per run and format at a time, since each format writes one file. An identical request
|
|
545
|
+
// (a double click) joins the encode in flight; a different poster frame is refused until it ends,
|
|
546
|
+
// so two encodes never race to replace the same file.
|
|
547
|
+
const exportsInFlight = new Map();
|
|
548
|
+
const demoRun = async (id) => {
|
|
549
|
+
const views = await adapter.sessionsDetail();
|
|
550
|
+
const run = views.find((v) => v.session.id === id);
|
|
551
|
+
return run !== undefined && isDemoRun(run) ? run : null;
|
|
552
|
+
};
|
|
553
|
+
// A run launched from the demo preset stays one, and the file route needs nothing else from it: the
|
|
554
|
+
// `<video>` asks in many Range requests, so each is not a read of every run.
|
|
555
|
+
const knownDemoRuns = new Set();
|
|
556
|
+
const isKnownDemoRun = async (id) => {
|
|
557
|
+
if (knownDemoRuns.has(id))
|
|
558
|
+
return true;
|
|
559
|
+
const run = await demoRun(id);
|
|
560
|
+
if (run !== null)
|
|
561
|
+
knownDemoRuns.add(id);
|
|
562
|
+
return run !== null;
|
|
563
|
+
};
|
|
564
|
+
app.post(`${V}/projects/:id/demo`, {
|
|
565
|
+
config: {
|
|
566
|
+
manifest: {
|
|
567
|
+
requestType: 'DemoLaunchBody',
|
|
568
|
+
responseType: 'DemoLaunchResponse',
|
|
569
|
+
// Every non-201 besides the body 400 is POST /runs' own answer, passed through.
|
|
570
|
+
statusCodes: [201, 400, 404, 409, 422, 501],
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
}, async (req, reply) => {
|
|
574
|
+
const parsed = DemoLaunchSchema.safeParse(req.body ?? {});
|
|
575
|
+
if (!parsed.success) {
|
|
576
|
+
return reply.code(400).send({ error: 'Invalid demo body', details: parsed.error.issues });
|
|
577
|
+
}
|
|
578
|
+
const projectId = req.params.id;
|
|
579
|
+
const runId = randomUUID();
|
|
580
|
+
const root = demoRootDir(runId);
|
|
581
|
+
await fsp.mkdir(root, { recursive: true });
|
|
582
|
+
const briefPath = join(root, 'BRIEF.md');
|
|
583
|
+
await fsp.writeFile(briefPath, demoBrief(root, parsed.data), 'utf8');
|
|
584
|
+
const auth = req.headers.authorization;
|
|
585
|
+
demoLaunchRoots.set(runId, root);
|
|
586
|
+
const launched = await app
|
|
587
|
+
.inject({
|
|
588
|
+
method: 'POST',
|
|
589
|
+
url: `${V}/runs`,
|
|
590
|
+
headers: { 'content-type': 'application/json', ...(auth !== undefined ? { authorization: auth } : {}) },
|
|
591
|
+
payload: {
|
|
592
|
+
problem: `Make a demo of ${parsed.data.url} with the wicked-garden-demo skill: follow the demo brief at ${briefPath}. Demo root: ${root}`,
|
|
593
|
+
sessionId: runId,
|
|
594
|
+
// Unfiled (studio's `default` mount) is no project to file into: the run launches unfiled.
|
|
595
|
+
...(projectId !== UNFILED_PROJECT ? { projectId } : {}),
|
|
596
|
+
workflow: DEMO_PRESET,
|
|
597
|
+
deliver: 'none',
|
|
598
|
+
...(parsed.data.clisJson !== undefined ? { clisJson: parsed.data.clisJson } : {}),
|
|
599
|
+
},
|
|
600
|
+
})
|
|
601
|
+
.finally(() => demoLaunchRoots.delete(runId));
|
|
602
|
+
if (launched.statusCode !== 201) {
|
|
603
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
604
|
+
return reply.code(launched.statusCode).type('application/json').send(launched.body);
|
|
605
|
+
}
|
|
606
|
+
return reply.code(201).send({ runId });
|
|
607
|
+
});
|
|
608
|
+
app.get(`${V}/runs/:id/demo`, { config: { manifest: { responseType: 'DemoView', statusCodes: [200, 404] } } }, async (req, reply) => {
|
|
609
|
+
const run = await demoRun(req.params.id);
|
|
610
|
+
if (run === null)
|
|
611
|
+
return reply.code(404).send({ error: 'no demo run with that id' });
|
|
612
|
+
return demoView(adapter, run);
|
|
613
|
+
});
|
|
614
|
+
app.get(`${V}/runs/:id/demo/file`, { config: { manifest: { responseType: 'binary (video/mp4, image/png, text)', statusCodes: [200, 206, 400, 404, 416] } } }, async (req, reply) => {
|
|
615
|
+
const { id } = req.params;
|
|
616
|
+
const rel = req.query.path;
|
|
617
|
+
if (typeof rel !== 'string')
|
|
618
|
+
return reply.code(400).send({ error: '`path` is required, once' });
|
|
619
|
+
const type = DEMO_FILE_TYPES[extname(rel).toLowerCase()];
|
|
620
|
+
if (type === undefined) {
|
|
621
|
+
return reply.code(400).send({ error: `\`path\` must be one of ${Object.keys(DEMO_FILE_TYPES).join(', ')}` });
|
|
622
|
+
}
|
|
623
|
+
if (!(await isKnownDemoRun(id)))
|
|
624
|
+
return reply.code(404).send({ error: 'no demo run with that id' });
|
|
625
|
+
const target = await containedPath(demoRootDir(id), rel);
|
|
626
|
+
if (target === null)
|
|
627
|
+
return reply.code(404).send({ error: `no such demo file: ${rel}` });
|
|
628
|
+
const st = await fsp.stat(target);
|
|
629
|
+
if (!st.isFile())
|
|
630
|
+
return reply.code(404).send({ error: `no such demo file: ${rel}` });
|
|
631
|
+
reply.header('content-type', type).header('accept-ranges', 'bytes').header('cache-control', 'no-store');
|
|
632
|
+
const range = parseRange(req.headers.range, st.size);
|
|
633
|
+
if (range === 'bad') {
|
|
634
|
+
return reply.code(416).header('content-range', `bytes */${st.size}`).send();
|
|
635
|
+
}
|
|
636
|
+
if (range === null) {
|
|
637
|
+
reply.header('content-length', String(st.size));
|
|
638
|
+
return reply.send(createReadStream(target));
|
|
639
|
+
}
|
|
640
|
+
reply
|
|
641
|
+
.code(206)
|
|
642
|
+
.header('content-range', `bytes ${range.start}-${range.end}/${st.size}`)
|
|
643
|
+
.header('content-length', String(range.end - range.start + 1));
|
|
644
|
+
return reply.send(createReadStream(target, { start: range.start, end: range.end }));
|
|
645
|
+
});
|
|
646
|
+
app.post(`${V}/runs/:id/demo/export`, {
|
|
647
|
+
config: {
|
|
648
|
+
manifest: {
|
|
649
|
+
requestType: 'DemoExportBody',
|
|
650
|
+
responseType: 'DemoExportResponse',
|
|
651
|
+
statusCodes: [200, 400, 404, 409, 422, 502, 503, 504],
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
}, async (req, reply) => {
|
|
655
|
+
const { id } = req.params;
|
|
656
|
+
const parsed = DemoExportSchema.safeParse(req.body ?? {});
|
|
657
|
+
if (!parsed.success)
|
|
658
|
+
return reply.code(400).send({ error: 'Invalid export body', details: parsed.error.issues });
|
|
659
|
+
const run = await demoRun(id);
|
|
660
|
+
if (run === null)
|
|
661
|
+
return reply.code(404).send({ error: 'no demo run with that id' });
|
|
662
|
+
// Only while none of the run's workers can write the root: at the review gate, or once the run
|
|
663
|
+
// has ended. The export reads and writes inside that root across a seconds-long encode.
|
|
664
|
+
if (!DEMO_EXPORT_STAGES.has(demoStage(run))) {
|
|
665
|
+
return reply.code(409).send({ error: 'a demo can be exported at the review gate or once the run has ended' });
|
|
666
|
+
}
|
|
667
|
+
const { format, atSec } = parsed.data;
|
|
668
|
+
const key = `${id}\0${format}`;
|
|
669
|
+
let pending = exportsInFlight.get(key);
|
|
670
|
+
if (pending !== undefined && pending.atSec !== atSec) {
|
|
671
|
+
return reply.code(409).send({ error: `a ${format} export of this demo is already running; try again when it ends` });
|
|
672
|
+
}
|
|
673
|
+
if (pending === undefined) {
|
|
674
|
+
// Re-read at publication: a gate answered during the encode may have resumed the recorder.
|
|
675
|
+
const stillExportable = async () => {
|
|
676
|
+
const now = await demoRun(id);
|
|
677
|
+
return now !== null && DEMO_EXPORT_STAGES.has(demoStage(now));
|
|
678
|
+
};
|
|
679
|
+
pending = {
|
|
680
|
+
atSec,
|
|
681
|
+
answer: encodeDemoExport(id, format, atSec, stillExportable).finally(() => exportsInFlight.delete(key)),
|
|
682
|
+
};
|
|
683
|
+
exportsInFlight.set(key, pending);
|
|
684
|
+
}
|
|
685
|
+
const answer = await pending.answer;
|
|
686
|
+
return reply.code(answer.status).send(answer.body);
|
|
687
|
+
});
|
|
688
|
+
app.put(`${V}/runs/:id/demo/script`, { config: { manifest: { requestType: 'DemoScriptBody', responseType: '{ bytes: number }', statusCodes: [200, 400, 404, 409, 413] } } }, async (req, reply) => {
|
|
689
|
+
const { id } = req.params;
|
|
690
|
+
const parsed = DemoScriptSchema.safeParse(req.body ?? {});
|
|
691
|
+
if (!parsed.success)
|
|
692
|
+
return reply.code(400).send({ error: 'Invalid script body', details: parsed.error.issues });
|
|
693
|
+
const bytes = Buffer.byteLength(parsed.data.content, 'utf8');
|
|
694
|
+
if (bytes > DEMO_SCRIPT_MAX_BYTES) {
|
|
695
|
+
return reply.code(413).send({ error: `the script is ${bytes} bytes, over the ${DEMO_SCRIPT_MAX_BYTES}-byte cap` });
|
|
696
|
+
}
|
|
697
|
+
const run = await demoRun(id);
|
|
698
|
+
if (run === null)
|
|
699
|
+
return reply.code(404).send({ error: 'no demo run with that id' });
|
|
700
|
+
// Only at the plan gate: before it the planner is still writing the script, after it the recorder
|
|
701
|
+
// has read it — an edit then would change a script nobody records.
|
|
702
|
+
if (demoStage(run) !== 'plan_gate') {
|
|
703
|
+
return reply.code(409).send({ error: 'the script can be edited only while the plan gate is open' });
|
|
704
|
+
}
|
|
705
|
+
// The resolved file, never the joined name: a worker-made symlink is followed only inside the root.
|
|
706
|
+
const target = await containedPath(demoRootDir(id), 'script.md');
|
|
707
|
+
if (target === null)
|
|
708
|
+
return reply.code(409).send({ error: 'the plan has not written script.md' });
|
|
709
|
+
await fsp.writeFile(target, parsed.data.content, 'utf8');
|
|
710
|
+
return { bytes };
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
// ── Walkthrough (WT-W1, DES-walkthrough-proof §4.2, §4.10) ────────────────────────────────────
|
|
714
|
+
/** The catalog id of the walkthrough author (an evaluator agent step). */
|
|
715
|
+
export const WALKTHROUGH_PLAN_CATALOG = 'walkthrough_plan';
|
|
716
|
+
/** The catalog id of the walkthrough recorder (the jailed, engine-run Tool step). */
|
|
717
|
+
export const WALKTHROUGH_REVIEW_CATALOG = 'walkthrough_review';
|
|
718
|
+
export { WALKTHROUGH_AUTHOR_SUBDIR, walkthroughRootDir } from '../core/walkthrough-root.js';
|
|
719
|
+
/** What `/walkthrough/file` serves, by extension: the demo's deliverable types (the take is a demo). */
|
|
720
|
+
const WALKTHROUGH_FILE_TYPES = DEMO_FILE_TYPES;
|
|
721
|
+
/** The biggest recorder file the view parses (result, progress, chapter list). */
|
|
722
|
+
const WALKTHROUGH_JSON_MAX_BYTES = 4 * 1024 * 1024;
|
|
723
|
+
/**
|
|
724
|
+
* The run's walkthrough pairs in plan order. A recorder pairs with the author its `depends_on` names,
|
|
725
|
+
* else the nearest author before it (the engine's `author_step_for`); an author with no recorder after
|
|
726
|
+
* it yet is a pair of its own (the walkthrough is still being written).
|
|
727
|
+
*/
|
|
728
|
+
export function walkthroughPairs(view) {
|
|
729
|
+
const units = [...view.units].sort((a, b) => a.ord - b.ord);
|
|
730
|
+
const plans = units.filter((u) => u.catalog === WALKTHROUGH_PLAN_CATALOG);
|
|
731
|
+
const pairs = [];
|
|
732
|
+
const paired = new Set();
|
|
733
|
+
for (const review of units.filter((u) => u.catalog === WALKTHROUGH_REVIEW_CATALOG)) {
|
|
734
|
+
const before = plans.filter((p) => p.ord < review.ord);
|
|
735
|
+
const deps = review.depends_on;
|
|
736
|
+
const named = Array.isArray(deps) ? before.find((p) => deps.includes(stepIdOf(view, p))) : undefined;
|
|
737
|
+
const plan = named ?? before.at(-1) ?? null;
|
|
738
|
+
if (plan !== null)
|
|
739
|
+
paired.add(plan);
|
|
740
|
+
pairs.push({ review, plan });
|
|
741
|
+
}
|
|
742
|
+
// An author no recorder resolves to is a pair of its own: its walkthrough is still being written.
|
|
743
|
+
for (const plan of plans) {
|
|
744
|
+
if (!paired.has(plan))
|
|
745
|
+
pairs.push({ review: null, plan });
|
|
746
|
+
}
|
|
747
|
+
// Newest = the latest AUTHOR (a pair's age is when its storyline was written); a recorder with no
|
|
748
|
+
// author before it sorts by its own position.
|
|
749
|
+
return pairs.sort((a, b) => (a.plan ?? a.review).ord - (b.plan ?? b.review).ord);
|
|
750
|
+
}
|
|
751
|
+
/** The pair the view and the file route read: the newest, or the one `step` (its review or plan step id) names. */
|
|
752
|
+
function selectPair(view, step) {
|
|
753
|
+
const pairs = walkthroughPairs(view);
|
|
754
|
+
if (step === undefined)
|
|
755
|
+
return pairs.at(-1);
|
|
756
|
+
return pairs.find((p) => (p.review !== null && stepIdOf(view, p.review) === step) || (p.plan !== null && stepIdOf(view, p.plan) === step));
|
|
757
|
+
}
|
|
758
|
+
/** A proof-root JSON file, parsed, or `undefined` when it is absent, oversized, outside the root or not JSON. */
|
|
759
|
+
async function readRootJson(root, rel) {
|
|
760
|
+
const path = await containedPath(root, rel);
|
|
761
|
+
if (path === null)
|
|
762
|
+
return undefined;
|
|
763
|
+
try {
|
|
764
|
+
const st = await fsp.stat(path);
|
|
765
|
+
if (!st.isFile() || st.size > WALKTHROUGH_JSON_MAX_BYTES)
|
|
766
|
+
return undefined;
|
|
767
|
+
return JSON.parse(await fsp.readFile(path, 'utf8'));
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
return undefined;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const OVERALL = { PASS: 'passed', FAIL: 'failed', INCONCLUSIVE: 'inconclusive' };
|
|
774
|
+
const RUNNING_STATES = new Set(['starting_app', 'recording', 'judging']);
|
|
775
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
|
|
776
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
777
|
+
const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
778
|
+
const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
779
|
+
function parseVerdict(v) {
|
|
780
|
+
return v === 'PASS' || v === 'FAIL' || v === 'INCONCLUSIVE' ? v : null;
|
|
781
|
+
}
|
|
782
|
+
function parseChecks(v) {
|
|
783
|
+
const out = [];
|
|
784
|
+
for (const raw of Array.isArray(v) ? v : []) {
|
|
785
|
+
const c = record(raw);
|
|
786
|
+
if (c === null || str(c.id) === null)
|
|
787
|
+
continue;
|
|
788
|
+
out.push({
|
|
789
|
+
id: c.id,
|
|
790
|
+
kind: str(c.kind) ?? 'unknown',
|
|
791
|
+
sentence: str(c.sentence) ?? '',
|
|
792
|
+
passed: typeof c.passed === 'boolean' ? c.passed : null,
|
|
793
|
+
atSec: num(c.at_sec),
|
|
794
|
+
evidence: strs(c.evidence),
|
|
795
|
+
vaultEntry: str(c.vault_entry),
|
|
796
|
+
detail: str(c.detail),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
return out;
|
|
800
|
+
}
|
|
801
|
+
function parseLegs(v) {
|
|
802
|
+
const out = [];
|
|
803
|
+
for (const raw of Array.isArray(v) ? v : []) {
|
|
804
|
+
const l = record(raw);
|
|
805
|
+
if (l === null || str(l.leg) === null)
|
|
806
|
+
continue;
|
|
807
|
+
out.push({ leg: l.leg, claim_level: str(l.claim_level) ?? '', reason: str(l.reason) ?? '' });
|
|
808
|
+
}
|
|
809
|
+
return out;
|
|
810
|
+
}
|
|
811
|
+
/** The chapters: the planned list (`chapters.json`), then any chapter only the judge's result names. */
|
|
812
|
+
async function walkthroughChapters(root, result) {
|
|
813
|
+
const planned = root === null ? [] : parseChapters(await readText(root, 'chapters.json'));
|
|
814
|
+
const judged = new Map();
|
|
815
|
+
for (const raw of Array.isArray(result?.chapters) ? result.chapters : []) {
|
|
816
|
+
const c = record(raw);
|
|
817
|
+
const key = str(c?.key);
|
|
818
|
+
if (c !== null && key !== null && /^[a-z0-9][a-z0-9-]*$/.test(key) && !judged.has(key))
|
|
819
|
+
judged.set(key, c);
|
|
820
|
+
}
|
|
821
|
+
const base = [...planned];
|
|
822
|
+
for (const [key, c] of judged) {
|
|
823
|
+
if (!base.some((p) => p.key === key))
|
|
824
|
+
base.push({ key, title: str(c.title) ?? key, blurb: '', tags: [], resets: [] });
|
|
825
|
+
}
|
|
826
|
+
return Promise.all(base.map(async (ch, i) => {
|
|
827
|
+
const j = judged.get(ch.key) ?? null;
|
|
828
|
+
const frame = str(j?.failed_frame);
|
|
829
|
+
const verdict = parseVerdict(j?.verdict);
|
|
830
|
+
return {
|
|
831
|
+
...ch,
|
|
832
|
+
recorded: root !== null && (await isFile(root, `demo-video/segments/${ch.key}/segment.mp4`)),
|
|
833
|
+
index: i + 1,
|
|
834
|
+
total: base.length,
|
|
835
|
+
verdict,
|
|
836
|
+
takes: Math.max(1, Math.trunc(num(j?.takes) ?? 1)),
|
|
837
|
+
failedAtSec: verdict === 'FAIL' ? num(j?.failed_at_sec) : null,
|
|
838
|
+
failedFrame: root !== null && frame !== null && (await isFile(root, frame)) ? frame : null,
|
|
839
|
+
proves: strs(j?.proves),
|
|
840
|
+
legs: parseLegs(j?.legs),
|
|
841
|
+
checks: parseChecks(j?.checks),
|
|
842
|
+
};
|
|
843
|
+
}));
|
|
844
|
+
}
|
|
845
|
+
/** One recorder step's gate: its proof root and the seal in its persisted output (WT-W2). */
|
|
846
|
+
async function recorderGate(adapter, view, review) {
|
|
847
|
+
const output = await adapter.workOutput(coreUnitId(view.session.id, review)).catch(() => null);
|
|
848
|
+
return resolveWalkthroughGate({
|
|
849
|
+
runId: view.session.id,
|
|
850
|
+
stepId: stepIdOf(view, review),
|
|
851
|
+
proofRoot: await walkthroughProofRoot(view, review),
|
|
852
|
+
output,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* The walkthrough half of `GET /runs/:id/acceptance` (WT-W2): one gate per `walkthrough_review` step
|
|
857
|
+
* the requirement names (`phases`, step ids), and the per-creator-step check states from the NEWEST
|
|
858
|
+
* walkthrough (only a sealed one proves anything).
|
|
859
|
+
*/
|
|
860
|
+
export async function walkthroughAcceptance(adapter, view, phases) {
|
|
861
|
+
const wanted = new Set(phases);
|
|
862
|
+
const reviews = view.units.filter((u) => u.catalog === WALKTHROUGH_REVIEW_CATALOG && wanted.has(stepIdOf(view, u)));
|
|
863
|
+
if (reviews.length === 0)
|
|
864
|
+
return { gates: [], steps: [], newest: null };
|
|
865
|
+
const gates = await Promise.all(reviews.map((u) => recorderGate(adapter, view, u)));
|
|
866
|
+
const newestReview = walkthroughPairs(view).filter((p) => p.review !== null).at(-1)?.review ?? null;
|
|
867
|
+
const newest = newestReview !== null ? (gates.find((g) => g.stepId === stepIdOf(view, newestReview)) ?? null) : null;
|
|
868
|
+
return { gates, steps: walkthroughCheckStates(view, newest), newest };
|
|
869
|
+
}
|
|
870
|
+
/** Everything studio renders for one walkthrough pair (the newest, or the one `step` names). */
|
|
871
|
+
export async function walkthroughView(view, step, adapter) {
|
|
872
|
+
const runId = view.session.id;
|
|
873
|
+
const pair = selectPair(view, step);
|
|
874
|
+
if (step !== undefined && pair === undefined)
|
|
875
|
+
return null;
|
|
876
|
+
const evidenceRoot = view.session.evidence_root;
|
|
877
|
+
const hasEvidenceRoot = typeof evidenceRoot === 'string' && evidenceRoot !== '';
|
|
878
|
+
const review = pair?.review ?? null;
|
|
879
|
+
const plan = pair?.plan ?? null;
|
|
880
|
+
const root = review !== null ? await walkthroughProofRoot(view, review) : null;
|
|
881
|
+
const resultRaw = root !== null ? await readRootJson(root, 'result.json') : undefined;
|
|
882
|
+
const result = record(resultRaw);
|
|
883
|
+
const bound = review ?? plan;
|
|
884
|
+
const builders = [];
|
|
885
|
+
for (const u of [...view.units].sort((a, b) => a.ord - b.ord)) {
|
|
886
|
+
if (bound !== null && u.ord >= bound.ord)
|
|
887
|
+
break;
|
|
888
|
+
if (u.role === 'creator' && u.assigned_cli !== null && u.assigned_cli !== undefined && !builders.includes(u.assigned_cli)) {
|
|
889
|
+
builders.push(u.assigned_cli);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
let state;
|
|
893
|
+
let cause = null;
|
|
894
|
+
if (pair === undefined) {
|
|
895
|
+
state = 'authoring';
|
|
896
|
+
cause = 'no_walkthrough';
|
|
897
|
+
}
|
|
898
|
+
else if (plan !== null && (plan.status === 'pending' || plan.status === 'distributed')) {
|
|
899
|
+
state = 'authoring';
|
|
900
|
+
}
|
|
901
|
+
else if (plan !== null && plan.status === 'rejected') {
|
|
902
|
+
state = 'linting';
|
|
903
|
+
cause = plan.denial_reason ?? 'storyline_refused';
|
|
904
|
+
}
|
|
905
|
+
else if (review === null || review.status === 'pending') {
|
|
906
|
+
state = 'starting_app';
|
|
907
|
+
}
|
|
908
|
+
else if (review.status === 'distributed') {
|
|
909
|
+
const progress = root !== null ? record(await readRootJson(root, 'progress.json')) : null;
|
|
910
|
+
const s = str(progress?.state);
|
|
911
|
+
state = s !== null && RUNNING_STATES.has(s) ? s : 'starting_app';
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
const overall = str(result?.overall);
|
|
915
|
+
// Own keys only (Copilot on #758): `constructor` / `__proto__` must never read as a verdict.
|
|
916
|
+
if (overall !== null && Object.hasOwn(OVERALL, overall)) {
|
|
917
|
+
state = OVERALL[overall];
|
|
918
|
+
cause = str(result?.cause);
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
state = 'inconclusive';
|
|
922
|
+
cause = hasEvidenceRoot ? 'no_result' : 'no_evidence_root';
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
const mp4 = root !== null && ((await fileSize(root, 'demo-video/demo.mp4')) ?? 0) > 0 ? 'demo-video/demo.mp4' : null;
|
|
926
|
+
const poster = root !== null && (await isFile(root, 'demo-video/poster.jpg')) ? 'demo-video/poster.jpg' : null;
|
|
927
|
+
const markers = root !== null ? parseMarkers(await readText(root, 'demo-video/chapters.md')) : [];
|
|
928
|
+
// WT-W2: the take is trusted only through its seal, re-verified at this read.
|
|
929
|
+
const gate = review !== null && adapter !== undefined ? await recorderGate(adapter, view, review) : null;
|
|
930
|
+
return {
|
|
931
|
+
runId,
|
|
932
|
+
stepId: review !== null ? stepIdOf(view, review) : null,
|
|
933
|
+
planStepId: plan !== null ? stepIdOf(view, plan) : null,
|
|
934
|
+
state,
|
|
935
|
+
cause,
|
|
936
|
+
seat: { evaluator: plan?.assigned_cli ?? null, builders },
|
|
937
|
+
tree: str(result?.tree),
|
|
938
|
+
stale: false,
|
|
939
|
+
sealed: gate?.sealed ?? false,
|
|
940
|
+
video: { mp4, poster, markers },
|
|
941
|
+
chapters: await walkthroughChapters(root, result),
|
|
942
|
+
steps: gate !== null ? walkthroughCheckStates(view, gate) : [],
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
/** The biggest storyline the operator may PUT (the demo module shape is a few KB). */
|
|
946
|
+
export const STORYLINE_MAX_BYTES = 256 * 1024;
|
|
947
|
+
/** The storyline file the author writes, and crew's edit marker beside it (WT-W3). */
|
|
948
|
+
export const STORYLINE_FILE = 'storyline.mjs';
|
|
949
|
+
export const STORYLINE_EDIT_FILE = 'storyline.edit.json';
|
|
950
|
+
const PLAIN_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
951
|
+
/** A refusal whose message is safe to return (relative names only, never an absolute path). */
|
|
952
|
+
export class StorylineWriteError extends Error {
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* WT-W3 (DES-walkthrough-proof §4.8 "Edit the check"): write the operator's storyline for the pair's
|
|
956
|
+
* author step — `<evidence_root>/author/<plan step>/storyline.mjs` plus `storyline.edit.json`
|
|
957
|
+
* (`edited_by: "human"`) — atomically (temp file + rename), refusing any path that does not resolve
|
|
958
|
+
* to a plain directory under the evidence root's `author/` (a planted link cannot redirect the write).
|
|
959
|
+
*/
|
|
960
|
+
export async function writeStoryline(evidenceRoot, planStepId, text, marker) {
|
|
961
|
+
if (!PLAIN_SEGMENT.test(planStepId) || planStepId.startsWith('.')) {
|
|
962
|
+
throw new StorylineWriteError(`step id ${JSON.stringify(planStepId)} is not a plain path segment`);
|
|
963
|
+
}
|
|
964
|
+
const authorRoot = join(evidenceRoot, WALKTHROUGH_AUTHOR_SUBDIR);
|
|
965
|
+
const dir = join(authorRoot, planStepId);
|
|
966
|
+
// One level at a time, each checked with lstat BEFORE anything is created under it: a planted link
|
|
967
|
+
// at `author/` or `author/<step>` is refused, never followed (codex on WT-W3).
|
|
968
|
+
for (const p of [authorRoot, dir]) {
|
|
969
|
+
const st = await fsp.lstat(p).catch((err) => {
|
|
970
|
+
if (err.code === 'ENOENT')
|
|
971
|
+
return null;
|
|
972
|
+
throw err;
|
|
973
|
+
});
|
|
974
|
+
if (st === null)
|
|
975
|
+
await fsp.mkdir(p, { mode: 0o755 });
|
|
976
|
+
else if (st.isSymbolicLink() || !st.isDirectory())
|
|
977
|
+
throw new StorylineWriteError(`${relative(evidenceRoot, p)} is not a plain directory`);
|
|
978
|
+
}
|
|
979
|
+
const expected = join(await fsp.realpath(evidenceRoot), WALKTHROUGH_AUTHOR_SUBDIR, planStepId);
|
|
980
|
+
const realDir = await fsp.realpath(dir);
|
|
981
|
+
if (realDir !== expected)
|
|
982
|
+
throw new StorylineWriteError('the author directory resolves outside the evidence root');
|
|
983
|
+
const sha256 = createHash('sha256').update(text, 'utf8').digest('hex');
|
|
984
|
+
const full = { edited_by: 'human', sha256, ...marker };
|
|
985
|
+
const put = async (name, body) => {
|
|
986
|
+
const target = join(realDir, name);
|
|
987
|
+
const existing = await fsp.lstat(target).catch(() => null);
|
|
988
|
+
if (existing !== null && !existing.isFile())
|
|
989
|
+
throw new StorylineWriteError(`${name} exists and is not a plain file`);
|
|
990
|
+
const tmp = join(realDir, `.${name}.${randomUUID()}.tmp`);
|
|
991
|
+
await fsp.writeFile(tmp, body, { mode: 0o644, flag: 'wx' });
|
|
992
|
+
// No unit runs while the run is parked on the escalation (one cursor), so nothing should swap the
|
|
993
|
+
// directory under us; re-check anyway before the rename publishes the file.
|
|
994
|
+
if ((await fsp.realpath(dir)) !== expected) {
|
|
995
|
+
await fsp.rm(tmp, { force: true });
|
|
996
|
+
throw new StorylineWriteError('the author directory changed while the storyline was written');
|
|
997
|
+
}
|
|
998
|
+
await fsp.rename(tmp, target);
|
|
999
|
+
};
|
|
1000
|
+
// The marker first, then the storyline: a crash between them leaves a marker whose sha names no
|
|
1001
|
+
// storyline on disk, which the recorder treats as "not edited" — never a stale `human` on agent text.
|
|
1002
|
+
await put(STORYLINE_EDIT_FILE, `${JSON.stringify(full, null, 2)}\n`);
|
|
1003
|
+
await put(STORYLINE_FILE, text);
|
|
1004
|
+
return full;
|
|
1005
|
+
}
|
|
1006
|
+
export function registerWalkthroughRoutes(app, adapter, deps = {}) {
|
|
1007
|
+
const runById = async (id) => (await adapter.sessionsDetail()).find((v) => v.session.id === id) ?? null;
|
|
1008
|
+
// WT-W3: the operator edits the checks while the pair's escalation is open, then approves.
|
|
1009
|
+
app.put(`${V}/runs/:id/walkthrough/storyline`, {
|
|
1010
|
+
config: {
|
|
1011
|
+
manifest: { requestType: 'PutStorylineBody', responseType: 'PutStorylineResponse', statusCodes: [200, 400, 403, 404, 409, 413, 503] },
|
|
1012
|
+
},
|
|
1013
|
+
bodyLimit: STORYLINE_MAX_BYTES + 4096,
|
|
1014
|
+
}, async (req, reply) => {
|
|
1015
|
+
const { id } = req.params;
|
|
1016
|
+
const step = req.query.step;
|
|
1017
|
+
if (step !== undefined && typeof step !== 'string')
|
|
1018
|
+
return reply.code(400).send({ error: '`step` names one step, once' });
|
|
1019
|
+
const body = req.body;
|
|
1020
|
+
const text = body?.storyline;
|
|
1021
|
+
if (typeof text !== 'string' || text.trim() === '')
|
|
1022
|
+
return reply.code(400).send({ error: '`storyline` (the storyline module text) is required' });
|
|
1023
|
+
if (Buffer.byteLength(text, 'utf8') > STORYLINE_MAX_BYTES) {
|
|
1024
|
+
return reply.code(413).send({ error: `the storyline is over ${STORYLINE_MAX_BYTES} bytes` });
|
|
1025
|
+
}
|
|
1026
|
+
const actor = req.actor ?? LOCAL_ACTOR;
|
|
1027
|
+
// The checks are the test plan a walkthrough proves the work against: only a person edits them.
|
|
1028
|
+
if (actor.kind !== 'human')
|
|
1029
|
+
return reply.code(403).send({ error: 'only a human operator edits the walkthrough checks' });
|
|
1030
|
+
const run = await runById(id);
|
|
1031
|
+
if (run === null)
|
|
1032
|
+
return reply.code(404).send({ error: 'no run with that id' });
|
|
1033
|
+
// Every pair the selector names (an author recorded by two reviews names both), or the newest.
|
|
1034
|
+
const named = step === undefined
|
|
1035
|
+
? [selectPair(run, undefined)].filter((p) => p !== undefined)
|
|
1036
|
+
: walkthroughPairs(run).filter((p) => (p.review !== null && stepIdOf(run, p.review) === step) || (p.plan !== null && stepIdOf(run, p.plan) === step));
|
|
1037
|
+
if (named.length === 0)
|
|
1038
|
+
return reply.code(404).send({ error: `run ${id} has no walkthrough step named ${String(step)}` });
|
|
1039
|
+
// Accepted ONLY while that pair's escalation gate is open: the run is parked on a denied unit of
|
|
1040
|
+
// the pair (the cursor stays on it, §4.8), and the open gate is that unit's.
|
|
1041
|
+
const noEscalation = (why) => reply.code(409).send({ error: `the storyline can be edited only while this walkthrough's escalation is open: ${why}`, code: 'no_open_escalation' });
|
|
1042
|
+
if (run.session.status !== 'awaiting_human')
|
|
1043
|
+
return noEscalation(`the run is ${run.session.status}`);
|
|
1044
|
+
if (deps.resolveOpenGate === undefined)
|
|
1045
|
+
return reply.code(503).send({ error: 'this daemon cannot resolve the open gate' });
|
|
1046
|
+
const open = await deps.resolveOpenGate(id);
|
|
1047
|
+
if (open === 'no-log')
|
|
1048
|
+
return reply.code(503).send({ error: 'gate history is unavailable: this wicked-core build has no event-log read binding' });
|
|
1049
|
+
if (open === null)
|
|
1050
|
+
return noEscalation('no gate is open');
|
|
1051
|
+
const isEscalated = (u) => u !== null && u.ord === open.ord && u.denial_reason !== null;
|
|
1052
|
+
const pair = named.find((p) => isEscalated(p.review) || isEscalated(p.plan));
|
|
1053
|
+
if (pair === undefined)
|
|
1054
|
+
return noEscalation(`the open gate is before unit ${open.ord}, not a denied step of this walkthrough`);
|
|
1055
|
+
if (pair.plan === null)
|
|
1056
|
+
return reply.code(409).send({ error: 'this walkthrough has no author step whose storyline could be edited', code: 'no_author' });
|
|
1057
|
+
const escalated = isEscalated(pair.review) ? pair.review : pair.plan;
|
|
1058
|
+
const evidenceRoot = run.session.evidence_root;
|
|
1059
|
+
if (typeof evidenceRoot !== 'string' || evidenceRoot === '')
|
|
1060
|
+
return reply.code(409).send({ error: 'this run has no evidence root', code: 'no_evidence_root' });
|
|
1061
|
+
const planStepId = stepIdOf(run, pair.plan);
|
|
1062
|
+
let marker;
|
|
1063
|
+
try {
|
|
1064
|
+
marker = await writeStoryline(evidenceRoot, planStepId, text, { actor: actor.id, at: new Date().toISOString(), ord: escalated.ord });
|
|
1065
|
+
}
|
|
1066
|
+
catch (err) {
|
|
1067
|
+
// A refusal says what was wrong in relative names; any other failure says only that it failed.
|
|
1068
|
+
const why = err instanceof StorylineWriteError ? err.message : 'the author directory could not be written';
|
|
1069
|
+
return reply.code(409).send({ error: `the storyline was not written: ${why}`, code: 'write_refused' });
|
|
1070
|
+
}
|
|
1071
|
+
// The escalation could have been answered while the file was written: say so, rather than a
|
|
1072
|
+
// 200 that implies the next recording uses it (codex on WT-W3).
|
|
1073
|
+
const after = await deps.resolveOpenGate(id);
|
|
1074
|
+
const still = (await runById(id))?.session.status === 'awaiting_human' && after !== null && after !== 'no-log' && after.ord === open.ord;
|
|
1075
|
+
if (!still) {
|
|
1076
|
+
return reply.code(409).send({
|
|
1077
|
+
error: 'the storyline was written, but the escalation was answered meanwhile; open the walkthrough again before relying on it',
|
|
1078
|
+
code: 'escalation_closed',
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
deps.audit?.record('walkthrough.storyline.edited', actor, {
|
|
1082
|
+
runId: id,
|
|
1083
|
+
detail: { planStepId, ord: escalated.ord, sha256: marker.sha256, bytes: Buffer.byteLength(text, 'utf8') },
|
|
1084
|
+
});
|
|
1085
|
+
const out = { runId: id, planStepId, sha256: marker.sha256, edited_by: 'human', at: marker.at };
|
|
1086
|
+
return out;
|
|
1087
|
+
});
|
|
1088
|
+
app.get(`${V}/runs/:id/walkthrough`, { config: { manifest: { responseType: 'WalkthroughView', statusCodes: [200, 400, 404] } } }, async (req, reply) => {
|
|
1089
|
+
const { id } = req.params;
|
|
1090
|
+
const step = req.query.step;
|
|
1091
|
+
if (step !== undefined && typeof step !== 'string')
|
|
1092
|
+
return reply.code(400).send({ error: '`step` names one step, once' });
|
|
1093
|
+
const run = await runById(id);
|
|
1094
|
+
if (run === null)
|
|
1095
|
+
return reply.code(404).send({ error: 'no run with that id' });
|
|
1096
|
+
const view = await walkthroughView(run, step, adapter);
|
|
1097
|
+
if (view === null)
|
|
1098
|
+
return reply.code(404).send({ error: `run ${id} has no walkthrough step named ${String(step)}` });
|
|
1099
|
+
return view;
|
|
1100
|
+
});
|
|
1101
|
+
app.get(`${V}/runs/:id/walkthrough/file`, { config: { manifest: { responseType: 'binary (video/mp4, image/png, text)', statusCodes: [200, 206, 400, 404, 416] } } }, async (req, reply) => {
|
|
1102
|
+
const { id } = req.params;
|
|
1103
|
+
const q = req.query;
|
|
1104
|
+
if (typeof q.path !== 'string')
|
|
1105
|
+
return reply.code(400).send({ error: '`path` is required, once' });
|
|
1106
|
+
if (q.step !== undefined && typeof q.step !== 'string')
|
|
1107
|
+
return reply.code(400).send({ error: '`step` names one step, once' });
|
|
1108
|
+
const type = WALKTHROUGH_FILE_TYPES[extname(q.path).toLowerCase()];
|
|
1109
|
+
if (type === undefined) {
|
|
1110
|
+
return reply.code(400).send({ error: `\`path\` must be one of ${Object.keys(WALKTHROUGH_FILE_TYPES).join(', ')}` });
|
|
1111
|
+
}
|
|
1112
|
+
const run = await runById(id);
|
|
1113
|
+
if (run === null)
|
|
1114
|
+
return reply.code(404).send({ error: 'no run with that id' });
|
|
1115
|
+
// The same pair the view shows: with no step, a newer walkthrough still being written has no
|
|
1116
|
+
// files yet, and an older take is never served in its place.
|
|
1117
|
+
const pair = selectPair(run, q.step);
|
|
1118
|
+
const root = pair?.review != null ? await walkthroughProofRoot(run, pair.review) : null;
|
|
1119
|
+
if (root === null)
|
|
1120
|
+
return reply.code(404).send({ error: 'this run has no recorded walkthrough there' });
|
|
1121
|
+
const target = await containedPath(root, q.path);
|
|
1122
|
+
const st = target === null ? null : await fsp.stat(target).catch(() => null);
|
|
1123
|
+
if (target === null || st === null || !st.isFile())
|
|
1124
|
+
return reply.code(404).send({ error: `no such walkthrough file: ${q.path}` });
|
|
1125
|
+
reply.header('content-type', type).header('accept-ranges', 'bytes').header('cache-control', 'no-store');
|
|
1126
|
+
const range = parseRange(req.headers.range, st.size);
|
|
1127
|
+
if (range === 'bad')
|
|
1128
|
+
return reply.code(416).header('content-range', `bytes */${st.size}`).send();
|
|
1129
|
+
if (range === null) {
|
|
1130
|
+
reply.header('content-length', String(st.size));
|
|
1131
|
+
return reply.send(createReadStream(target));
|
|
1132
|
+
}
|
|
1133
|
+
reply
|
|
1134
|
+
.code(206)
|
|
1135
|
+
.header('content-range', `bytes ${range.start}-${range.end}/${st.size}`)
|
|
1136
|
+
.header('content-length', String(range.end - range.start + 1));
|
|
1137
|
+
return reply.send(createReadStream(target, { start: range.start, end: range.end }));
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
//# sourceMappingURL=recording.js.map
|