humanish 0.112.0 → 0.114.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/CHANGELOG.md +41 -0
- package/CONTEXT.md +1 -0
- package/README.md +12 -1
- package/TELEMETRY.md +21 -0
- package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
- package/dist/actors/codex/restricted-participant-policy.js +20 -2
- package/dist/actors/codex/restricted-participant.d.ts +1 -1
- package/dist/actors/codex/restricted-participant.js +2 -1
- package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
- package/dist/actors/computer-use/loop/debrief.js +57 -20
- package/dist/actors/computer-use/loop/ending.d.ts +5 -2
- package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
- package/dist/actors/computer-use/loop/impressions.js +40 -0
- package/dist/actors/computer-use/loop/session.d.ts +3 -1
- package/dist/actors/computer-use/loop/session.js +2 -0
- package/dist/actors/computer-use/loop/trace.js +2 -0
- package/dist/actors/computer-use/loop/types.d.ts +8 -1
- package/dist/actors/computer-use/loop.d.ts +2 -1
- package/dist/actors/computer-use/loop.js +15 -3
- package/dist/actors/computer-use/openai-conversation.d.ts +9 -4
- package/dist/actors/computer-use/openai-conversation.js +60 -29
- package/dist/actors/computer-use/openai-provider.js +18 -12
- package/dist/actors/contract.d.ts +47 -5
- package/dist/actors/contract.js +13 -0
- package/dist/analysis/admission.d.ts +37 -0
- package/dist/analysis/admission.js +60 -0
- package/dist/analysis/automatic-completion.d.ts +0 -6
- package/dist/analysis/automatic-completion.js +3 -3
- package/dist/analysis/automatic-config.d.ts +10 -2
- package/dist/analysis/automatic-config.js +29 -14
- package/dist/analysis/automatic.js +7 -0
- package/dist/analysis/evidence-sources.d.ts +3 -1
- package/dist/analysis/evidence-sources.js +10 -1
- package/dist/analysis/evidence.js +2 -2
- package/dist/analysis/execute.d.ts +20 -6
- package/dist/analysis/execute.js +61 -28
- package/dist/analysis/image-tokens.d.ts +2 -0
- package/dist/analysis/image-tokens.js +1 -1
- package/dist/analysis/job.d.ts +9 -1
- package/dist/analysis/job.js +11 -2
- package/dist/analysis/service.js +6 -3
- package/dist/cli/commands/analyze.js +7 -2
- package/dist/cli/commands/notes.d.ts +3 -0
- package/dist/cli/commands/notes.js +144 -0
- package/dist/cli/commands/observe.js +1 -1
- package/dist/cli/commands/runs.js +33 -10
- package/dist/cli/commands/setup.js +95 -6
- package/dist/cli/commands/study-format.js +7 -4
- package/dist/cli/commands/study-run.js +2 -1
- package/dist/cli/commands/study.js +1 -1
- package/dist/cli/commands/tui.js +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.js +18 -0
- package/dist/cli/findings.js +4 -1
- package/dist/cli/io.d.ts +3 -0
- package/dist/cli/io.js +11 -12
- package/dist/cli/program.js +37 -1
- package/dist/cli/secret-prompt.d.ts +5 -1
- package/dist/cli/secret-prompt.js +6 -2
- package/dist/cli/update-check-worker.d.ts +1 -0
- package/dist/cli/update-check-worker.js +5 -0
- package/dist/cli/update-check.d.ts +46 -0
- package/dist/cli/update-check.js +203 -0
- package/dist/evidence/encoded-text.d.ts +15 -2
- package/dist/evidence/encoded-text.js +68 -4
- package/dist/evidence/secret-scrub.d.ts +1 -1
- package/dist/evidence/secret-scrub.js +23 -2
- package/dist/feedback/draft.d.ts +19 -0
- package/dist/feedback/draft.js +69 -1
- package/dist/feedback/export-bundle.js +2 -2
- package/dist/feedback/export.js +47 -3
- package/dist/feedback/feedback.js +42 -2
- package/dist/keys/key-resolution.js +2 -0
- package/dist/keys/key-status.d.ts +37 -0
- package/dist/keys/key-status.js +68 -0
- package/dist/observer/artifact.d.ts +5 -0
- package/dist/observer/artifact.js +18 -2
- package/dist/observer/notes-route.d.ts +21 -0
- package/dist/observer/notes-route.js +140 -0
- package/dist/observer/pinned-files.d.ts +8 -1
- package/dist/observer/pinned-files.js +32 -3
- package/dist/observer/render.js +38 -14
- package/dist/observer/run-routes.d.ts +8 -2
- package/dist/observer/run-routes.js +47 -6
- package/dist/observer/serve.d.ts +3 -0
- package/dist/observer/serve.js +28 -3
- package/dist/observer-app.html +7 -6
- package/dist/routes/computer-use/local-vm.js +9 -2
- package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
- package/dist/routes/computer-use/participant-execution.js +9 -1
- package/dist/routes/computer-use/rerun-selection.js +23 -21
- package/dist/routes/computer-use/result.js +3 -2
- package/dist/routes/computer-use/self-report.js +7 -1
- package/dist/routes/computer-use/setup.js +14 -0
- package/dist/routes/computer-use/types.d.ts +4 -1
- package/dist/routes/shared-world/bundle-records.js +8 -7
- package/dist/routes/shared-world/bundle.js +1 -1
- package/dist/run/contained-output.d.ts +7 -0
- package/dist/run/contained-output.js +35 -1
- package/dist/run/detail.js +4 -4
- package/dist/run/note-files.d.ts +73 -0
- package/dist/run/note-files.js +184 -0
- package/dist/run/notes.d.ts +37 -0
- package/dist/run/notes.js +198 -0
- package/dist/run/participant-caption.d.ts +11 -1
- package/dist/run/participant-caption.js +30 -1
- package/dist/run/participant-records.d.ts +3 -1
- package/dist/run/participant-records.js +6 -0
- package/dist/run-study.js +17 -1
- package/dist/study/discover.js +6 -1
- package/dist/study/doctor.d.ts +2 -0
- package/dist/study/doctor.js +46 -8
- package/dist/study/init-templates.js +10 -5
- package/dist/study/plan.js +3 -2
- package/dist/study/preflight-result.d.ts +1 -0
- package/dist/study/preflight-result.js +3 -2
- package/dist/study/preflight.d.ts +2 -0
- package/dist/study/preflight.js +54 -14
- package/dist/study/summary.js +2 -2
- package/dist/study/warnings.d.ts +5 -0
- package/dist/study/warnings.js +79 -2
- package/dist/substrates/local/capacity.d.ts +75 -0
- package/dist/substrates/local/capacity.js +143 -0
- package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
- package/dist/substrates/local/firecracker-desktop.js +34 -3
- package/dist/substrates/local/runtime-host.d.ts +16 -3
- package/dist/substrates/local/runtime-host.js +49 -4
- package/dist/substrates/local/runtime.d.ts +10 -0
- package/dist/substrates/local/runtime.js +44 -5
- package/dist/tui-app.js +58 -58
- package/docs/architecture/actor-contract.md +20 -6
- package/docs/contracts/errors.md +2 -2
- package/docs/contracts/feedback.md +8 -1
- package/docs/contracts/run-bundle.md +57 -0
- package/docs/contracts/schemas.md +62 -36
- package/docs/contracts/study-analysis.md +41 -8
- package/docs/principles/invariants-and-defaults.md +2 -2
- package/docs/product/automatic-analysis.md +22 -16
- package/package.json +2 -2
- package/skills/humanish/SKILL.md +26 -10
|
@@ -6,8 +6,10 @@ import path from "node:path";
|
|
|
6
6
|
import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
|
|
7
7
|
import { bindExistingManagedHumanishOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
|
|
8
8
|
import { loadRunBundlePrepared } from "../run/locate.js";
|
|
9
|
+
import { readNotesForSharing } from "../run/notes.js";
|
|
10
|
+
import { cli } from "../cli/invocation.js";
|
|
9
11
|
import { verifyRunPrepared } from "../verify/verify.js";
|
|
10
|
-
import { buildAnalysisDraft, buildDraft, isUsableFeedbackCandidate, renderMarkdown, } from "./draft.js";
|
|
12
|
+
import { buildAnalysisDraft, buildDraft, isUsableFeedbackCandidate, renderMarkdown, withReviewerNotes, } from "./draft.js";
|
|
11
13
|
const FEEDBACK_RESULT_SCHEMA = "humanish.feedback-result.v1";
|
|
12
14
|
function summarizeCandidates(bundle) {
|
|
13
15
|
return bundle.feedbackCandidates
|
|
@@ -90,6 +92,14 @@ async function draftFeedbackBound(cwdInput, runInput, options = {}) {
|
|
|
90
92
|
},
|
|
91
93
|
};
|
|
92
94
|
}
|
|
95
|
+
// The notes the draft includes are this set, checked here: a note can be added or changed after
|
|
96
|
+
// verify read the run.
|
|
97
|
+
const shared = await readNotesForSharing(context.preparedRunPaths);
|
|
98
|
+
if (shared.finding !== null)
|
|
99
|
+
return {
|
|
100
|
+
context,
|
|
101
|
+
result: notesRefusal(cwd, runInput, context.storedRunId, verified, shared.finding),
|
|
102
|
+
};
|
|
93
103
|
const candidates = summarizeCandidates(context.loaded.bundle);
|
|
94
104
|
if (options.candidate !== undefined &&
|
|
95
105
|
!candidates.some((item) => item.id === options.candidate)) {
|
|
@@ -111,9 +121,11 @@ async function draftFeedbackBound(cwdInput, runInput, options = {}) {
|
|
|
111
121
|
};
|
|
112
122
|
}
|
|
113
123
|
const independent = options.analysis !== undefined || options.finding !== undefined;
|
|
114
|
-
const
|
|
124
|
+
const built = independent
|
|
115
125
|
? await buildAnalysisDraft(context, options)
|
|
116
126
|
: buildDraft(context.loaded.bundle, context.loaded.bundlePath, options.candidate);
|
|
127
|
+
const draft = built &&
|
|
128
|
+
withReviewerNotes(built, shared.notes, context.loaded.bundle, path.dirname(context.loaded.bundlePath));
|
|
117
129
|
if (!draft)
|
|
118
130
|
return {
|
|
119
131
|
context,
|
|
@@ -143,6 +155,34 @@ async function draftFeedbackBound(cwdInput, runInput, options = {}) {
|
|
|
143
155
|
},
|
|
144
156
|
};
|
|
145
157
|
}
|
|
158
|
+
/** The refusal of a draft whose reviewer notes, as read for it, hold what verify's scan flags. */
|
|
159
|
+
function notesRefusal(cwd, runInput, runId, verified, finding) {
|
|
160
|
+
return {
|
|
161
|
+
schema: FEEDBACK_RESULT_SCHEMA,
|
|
162
|
+
ok: false,
|
|
163
|
+
cwd,
|
|
164
|
+
run: runInput,
|
|
165
|
+
shareSafety: {
|
|
166
|
+
status: "blocked",
|
|
167
|
+
reasons: [
|
|
168
|
+
...verified.shareSafety.reasons,
|
|
169
|
+
finding === "sensitive"
|
|
170
|
+
? {
|
|
171
|
+
code: "PUBLIC_SAFETY_FINDINGS",
|
|
172
|
+
message: "The reviewer notes the draft would include match secret, token or local-path patterns.",
|
|
173
|
+
}
|
|
174
|
+
: {
|
|
175
|
+
code: "UNSCANNED_ARTIFACT",
|
|
176
|
+
message: "The reviewer notes the draft would include hold encoded text the scan cannot read.",
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
error: {
|
|
181
|
+
code: "HUMANISH_FEEDBACK_SHARE_SAFETY_BLOCKED",
|
|
182
|
+
message: `The run's reviewer notes hold text that looks like a secret, a token or a local path, so no draft was written. Run \`${cli(`verify --run ${runId}`)}\` and fix the note files in notes/.`,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
146
186
|
export async function verifyFeedback(cwdInput, runInput, options = {}) {
|
|
147
187
|
return (await verifyFeedbackBound(cwdInput, runInput, options)).result;
|
|
148
188
|
}
|
|
@@ -245,6 +245,8 @@ export function missingKeyHint(name) {
|
|
|
245
245
|
return `run \`${cli("keys set openai")}\``;
|
|
246
246
|
case "ANTHROPIC_API_KEY":
|
|
247
247
|
return `run \`${cli("keys set anthropic")}\``;
|
|
248
|
+
case "AGENTMAIL_API_KEY":
|
|
249
|
+
return `run \`${cli("keys set agentmail")}\``;
|
|
248
250
|
default:
|
|
249
251
|
return `run \`${cli(`keys set ${name}`)}\``;
|
|
250
252
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type KeyResolutionDeps, type KeySourceProbe } from "./key-resolution.js";
|
|
2
|
+
interface KeyStatusRow extends KeySourceProbe {
|
|
3
|
+
/** What humanish uses the key for, in a few words. */
|
|
4
|
+
use: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function keyStatus(args: {
|
|
7
|
+
cwd: string;
|
|
8
|
+
env: NodeJS.ProcessEnv;
|
|
9
|
+
deps?: KeyResolutionDeps;
|
|
10
|
+
}): Promise<KeyStatusRow[]>;
|
|
11
|
+
/** Names in the user store that the status does not list, such as ANTHROPIC_API_KEY. */
|
|
12
|
+
export declare function otherStoredKeys(env: NodeJS.ProcessEnv, deps?: KeyResolutionDeps): string[];
|
|
13
|
+
export declare function formatKeyStatus(rows: readonly KeyStatusRow[], otherStored?: readonly string[]): string;
|
|
14
|
+
interface MissingKeysOutcome {
|
|
15
|
+
stored: string[];
|
|
16
|
+
/** Keys left unset because the person pressed Enter without a value. */
|
|
17
|
+
skipped: string[];
|
|
18
|
+
/** Keys whose value the store refused, with the reason. */
|
|
19
|
+
rejected?: Array<{
|
|
20
|
+
name: string;
|
|
21
|
+
message: string;
|
|
22
|
+
}>;
|
|
23
|
+
/** The key whose prompt was cancelled. No later key was asked for. */
|
|
24
|
+
stoppedAt?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* `humanish keys set` with no key named: ask for each missing key in turn and store each answer in
|
|
28
|
+
* the user store. An empty answer skips that key; a cancelled prompt (null) stops the walk and
|
|
29
|
+
* keeps what was already stored.
|
|
30
|
+
*/
|
|
31
|
+
export declare function askForMissingKeys(args: {
|
|
32
|
+
rows: readonly KeyStatusRow[];
|
|
33
|
+
env: NodeJS.ProcessEnv;
|
|
34
|
+
deps?: KeyResolutionDeps;
|
|
35
|
+
prompt: (label: string) => Promise<string | null>;
|
|
36
|
+
}): Promise<MissingKeysOutcome>;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// `humanish keys`: which provider keys are set and where each comes from, through the same chain a
|
|
2
|
+
// live command resolves. Never a value.
|
|
3
|
+
import { cli } from "../cli/invocation.js";
|
|
4
|
+
import { listUserKeys, probeKeySources, setUserKey, } from "./key-resolution.js";
|
|
5
|
+
/**
|
|
6
|
+
* The keys a person sets for humanish, in the order the status lists them and `keys set` asks for
|
|
7
|
+
* them. ANTHROPIC_API_KEY is left out: a Claude Code participant runs on its own login and never
|
|
8
|
+
* receives it. CODEX_API_KEY is an alternative spelling of the OpenAI key for terminal studies.
|
|
9
|
+
*/
|
|
10
|
+
const STATUS_KEYS = [
|
|
11
|
+
{ name: "E2B_API_KEY", use: "hosted desktops" },
|
|
12
|
+
{ name: "OPENAI_API_KEY", use: "participant model and analysis" },
|
|
13
|
+
{ name: "GH_TOKEN", use: "private repository subjects" },
|
|
14
|
+
{ name: "AGENTMAIL_API_KEY", use: "email in studies" },
|
|
15
|
+
];
|
|
16
|
+
export async function keyStatus(args) {
|
|
17
|
+
const probes = await probeKeySources(STATUS_KEYS.map((key) => key.name), {
|
|
18
|
+
cwd: args.cwd,
|
|
19
|
+
env: args.env,
|
|
20
|
+
...(args.deps === undefined ? {} : { deps: args.deps }),
|
|
21
|
+
});
|
|
22
|
+
return probes.map((probe, index) => ({ ...probe, use: STATUS_KEYS[index].use }));
|
|
23
|
+
}
|
|
24
|
+
/** Names in the user store that the status does not list, such as ANTHROPIC_API_KEY. */
|
|
25
|
+
export function otherStoredKeys(env, deps = {}) {
|
|
26
|
+
const listed = new Set(STATUS_KEYS.map((key) => key.name));
|
|
27
|
+
return listUserKeys(env, deps).filter((name) => !listed.has(name));
|
|
28
|
+
}
|
|
29
|
+
export function formatKeyStatus(rows, otherStored = []) {
|
|
30
|
+
const lines = rows.map((row) => `${row.name} (${row.use}): ${row.source === null ? `missing; ${row.hint}` : `set, from ${row.source}`}`);
|
|
31
|
+
if (otherStored.length > 0)
|
|
32
|
+
lines.push(`Also in the user store: ${otherStored.join(", ")}. humanish does not use ${otherStored.length === 1 ? "it" : "them"} today.`);
|
|
33
|
+
const missing = rows.some((row) => row.source === null);
|
|
34
|
+
lines.push("", missing
|
|
35
|
+
? `Values are never printed. \`${cli("keys set")}\` asks for each missing key in turn.`
|
|
36
|
+
: "Values are never printed.");
|
|
37
|
+
return `${lines.join("\n")}\n`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `humanish keys set` with no key named: ask for each missing key in turn and store each answer in
|
|
41
|
+
* the user store. An empty answer skips that key; a cancelled prompt (null) stops the walk and
|
|
42
|
+
* keeps what was already stored.
|
|
43
|
+
*/
|
|
44
|
+
export async function askForMissingKeys(args) {
|
|
45
|
+
const outcome = { stored: [], skipped: [] };
|
|
46
|
+
for (const row of args.rows) {
|
|
47
|
+
if (row.source !== null)
|
|
48
|
+
continue;
|
|
49
|
+
const value = await args.prompt(`${row.name} for ${row.use}`);
|
|
50
|
+
if (value === null)
|
|
51
|
+
return { ...outcome, stoppedAt: row.name };
|
|
52
|
+
if (value === "") {
|
|
53
|
+
outcome.skipped.push(row.name);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
setUserKey(row.name, value, args.env, args.deps);
|
|
58
|
+
outcome.stored.push(row.name);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
(outcome.rejected ??= []).push({
|
|
62
|
+
name: row.name,
|
|
63
|
+
message: error instanceof Error ? error.message : String(error),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return outcome;
|
|
68
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LoadedAnalysis } from "../analysis/types.js";
|
|
2
|
+
import type { RunNotes } from "../run/notes.js";
|
|
2
3
|
import { type ObserverData } from "./data.js";
|
|
3
4
|
/**
|
|
4
5
|
* Resolve (and in a repo checkout, build) the Observer artifact before any run or
|
|
@@ -19,5 +20,9 @@ export type ObserverExportAssets = Record<string, {
|
|
|
19
20
|
export declare function renderObserverHtml(data: ObserverData, options?: {
|
|
20
21
|
snapshot?: boolean;
|
|
21
22
|
analysis?: LoadedAnalysis;
|
|
23
|
+
/** The run's reviewer notes, shown read-only unless `notesToken` is set. */
|
|
24
|
+
notes?: RunNotes | null;
|
|
25
|
+
/** The serving process's token for adding a note. Only a loopback server's page carries it. */
|
|
26
|
+
notesToken?: string;
|
|
22
27
|
assets?: ObserverExportAssets;
|
|
23
28
|
}): string;
|
|
@@ -159,7 +159,7 @@ function renderExportAssets(assets) {
|
|
|
159
159
|
})
|
|
160
160
|
.join("");
|
|
161
161
|
}
|
|
162
|
-
function renderObserverAppHtml(data, snapshot, analysis, assets) {
|
|
162
|
+
function renderObserverAppHtml(data, snapshot, analysis, notes, assets) {
|
|
163
163
|
const artifact = loadObserverArtifact();
|
|
164
164
|
// A renderer-owned boot marker, outside the untrusted run-data contract. Only
|
|
165
165
|
// portable HTML exports opt out of a live feed; ordinary served pages still poll.
|
|
@@ -168,6 +168,7 @@ function renderObserverAppHtml(data, snapshot, analysis, assets) {
|
|
|
168
168
|
: artifact)
|
|
169
169
|
.replace(OBSERVER_DATA_SLOT, () => `<script id="observer-data" type="application/json">${escapeJsonScript(data)}</script>`)
|
|
170
170
|
.replace(/<script id="study-analysis" type="application\/json">[\s\S]*?<\/script>/, () => `<script id="study-analysis" type="application/json">${escapeJsonScript(analysis)}</script>`)
|
|
171
|
+
.replace(/<script id="run-notes" type="application\/json">[\s\S]*?<\/script>/, () => `<script id="run-notes" type="application/json">${escapeJsonScript(notes)}</script>`)
|
|
171
172
|
.replace("</body>", () => `${renderExportAssets(assets)}</body>`)
|
|
172
173
|
.replace(/<title>[^<]*<\/title>/, () => `<title>humanish Observer · ${escapeHtml(data.run.runId)}</title>`);
|
|
173
174
|
}
|
|
@@ -207,7 +208,22 @@ export function renderObserverHtml(data, options = {}) {
|
|
|
207
208
|
},
|
|
208
209
|
};
|
|
209
210
|
}
|
|
210
|
-
|
|
211
|
+
// A snapshot is a file anyone may open later, so it never carries a server's token.
|
|
212
|
+
const write = options.notesToken !== undefined && options.snapshot !== true
|
|
213
|
+
? { token: options.notesToken }
|
|
214
|
+
: null;
|
|
215
|
+
return renderObserverAppHtml(withObserverEndings(data), options.snapshot === true, projectShareCheckedAnalysis(analysis), {
|
|
216
|
+
// A run without notes renders the slot as before notes existed.
|
|
217
|
+
notes: options.notes == null ||
|
|
218
|
+
(options.notes.notes.length === 0 && options.notes.skipped.length === 0)
|
|
219
|
+
? null
|
|
220
|
+
: {
|
|
221
|
+
runId: options.notes.runId,
|
|
222
|
+
notes: options.notes.notes,
|
|
223
|
+
skipped: options.notes.skipped.length,
|
|
224
|
+
},
|
|
225
|
+
write,
|
|
226
|
+
}, options.assets ?? {});
|
|
211
227
|
}
|
|
212
228
|
function escapeHtml(value) {
|
|
213
229
|
return value.replace(/[&<>"']/g, (char) => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { PreparedRunArtifactPaths } from "../run/paths.js";
|
|
3
|
+
export declare const NOTES_PATH = "/api/notes";
|
|
4
|
+
export interface NotesWriterOptions {
|
|
5
|
+
/** An exposed server refuses every note and gives its pages no token. */
|
|
6
|
+
exposed: boolean;
|
|
7
|
+
/** The port the server listens on, once it does. */
|
|
8
|
+
port(): number;
|
|
9
|
+
/** The run's paths when this server serves it, otherwise null. */
|
|
10
|
+
resolveRun(runId: string): Promise<PreparedRunArtifactPaths | null>;
|
|
11
|
+
/** Renders the run's saved Observer page again after a note is added. */
|
|
12
|
+
refresh(prepared: PreparedRunArtifactPaths): Promise<unknown>;
|
|
13
|
+
}
|
|
14
|
+
export interface NotesWriter {
|
|
15
|
+
/** The token for a page this server renders for `request`, or undefined when it takes no notes. */
|
|
16
|
+
pageToken(request: Pick<IncomingMessage, "headers">): string | undefined;
|
|
17
|
+
handle(request: IncomingMessage, response: ServerResponse): Promise<void>;
|
|
18
|
+
/** Resolves once every saved page a note started rendering again is written. */
|
|
19
|
+
settled(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export declare function createNotesWriter(options: NotesWriterOptions): NotesWriter;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// POST /api/notes, the one write an Observer server accepts: a reviewer note on a run it serves.
|
|
2
|
+
// A server takes it only while it listens on loopback and is not exposed, with the token it put in
|
|
3
|
+
// the page it rendered, and from that page's own origin. Every other request is refused before its
|
|
4
|
+
// body is read past 16 KiB. A page from another site can send a POST to a loopback port, but it
|
|
5
|
+
// cannot read the token from the page or send this origin, and a name rebound to 127.0.0.1 arrives
|
|
6
|
+
// with its own Host.
|
|
7
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { addRunNote, MAX_NOTE_TEXT, } from "../run/notes.js";
|
|
9
|
+
import { isRecord } from "../run/type-guards.js";
|
|
10
|
+
import { cli } from "../cli/invocation.js";
|
|
11
|
+
import { hostAllowed } from "./http.js";
|
|
12
|
+
export const NOTES_PATH = "/api/notes";
|
|
13
|
+
const TOKEN_HEADER = "x-humanish-notes-token";
|
|
14
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
15
|
+
/** The HTTP status for a refusal from the notes store. */
|
|
16
|
+
const STORE_STATUS = {
|
|
17
|
+
HUMANISH_NOTE_INVALID: 400,
|
|
18
|
+
HUMANISH_NOTE_UNKNOWN_PARTICIPANT: 400,
|
|
19
|
+
HUMANISH_NOTE_OUTSIDE_RUN: 400,
|
|
20
|
+
HUMANISH_NOTE_NO_CLOCK: 409,
|
|
21
|
+
HUMANISH_INVALID_RUN_BUNDLE: 409,
|
|
22
|
+
HUMANISH_NOTES_UNREADABLE: 409,
|
|
23
|
+
HUMANISH_NOTES_FULL: 409,
|
|
24
|
+
HUMANISH_NOTE_ID_TAKEN: 409,
|
|
25
|
+
};
|
|
26
|
+
/** The Host values a loopback page uses for this port. */
|
|
27
|
+
function loopbackHosts(port) {
|
|
28
|
+
return new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
29
|
+
}
|
|
30
|
+
function reply(response, status, body, close = false) {
|
|
31
|
+
response.writeHead(status, {
|
|
32
|
+
"content-type": "application/json; charset=utf-8",
|
|
33
|
+
"cache-control": "no-store",
|
|
34
|
+
...(status === 405 ? { allow: "POST" } : {}),
|
|
35
|
+
// A refused request's body is never read, so the connection cannot carry another request.
|
|
36
|
+
...(close ? { connection: "close" } : {}),
|
|
37
|
+
});
|
|
38
|
+
response.end(`${JSON.stringify(body)}\n`);
|
|
39
|
+
}
|
|
40
|
+
function refuse(response, status, code, message) {
|
|
41
|
+
reply(response, status, { ok: false, error: { code, message } }, true);
|
|
42
|
+
}
|
|
43
|
+
/** The body, or null once it passes MAX_BODY_BYTES; reading stops there. */
|
|
44
|
+
function readBody(request) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const chunks = [];
|
|
47
|
+
let size = 0;
|
|
48
|
+
const onData = (chunk) => {
|
|
49
|
+
size += chunk.length;
|
|
50
|
+
if (size > MAX_BODY_BYTES) {
|
|
51
|
+
request.off("data", onData);
|
|
52
|
+
request.pause();
|
|
53
|
+
resolve(null);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
chunks.push(chunk);
|
|
57
|
+
};
|
|
58
|
+
request.on("data", onData);
|
|
59
|
+
request.once("end", () => resolve(Buffer.concat(chunks)));
|
|
60
|
+
request.once("error", reject);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function sameToken(sent, token) {
|
|
64
|
+
if (typeof sent !== "string")
|
|
65
|
+
return false;
|
|
66
|
+
const left = Buffer.from(sent);
|
|
67
|
+
const right = Buffer.from(token);
|
|
68
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
69
|
+
}
|
|
70
|
+
/** The note in a request body, or null when the body is not one. */
|
|
71
|
+
function noteInput(body) {
|
|
72
|
+
let value;
|
|
73
|
+
try {
|
|
74
|
+
value = JSON.parse(body.toString("utf8"));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (!isRecord(value) ||
|
|
80
|
+
typeof value.runId !== "string" ||
|
|
81
|
+
typeof value.atMs !== "number" ||
|
|
82
|
+
typeof value.text !== "string" ||
|
|
83
|
+
!(value.participant === undefined ||
|
|
84
|
+
value.participant === null ||
|
|
85
|
+
typeof value.participant === "string"))
|
|
86
|
+
return null;
|
|
87
|
+
return {
|
|
88
|
+
runId: value.runId,
|
|
89
|
+
atMs: value.atMs,
|
|
90
|
+
participant: typeof value.participant === "string" ? value.participant : null,
|
|
91
|
+
text: value.text,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function createNotesWriter(options) {
|
|
95
|
+
const token = randomBytes(32).toString("base64url");
|
|
96
|
+
// Renders run one after another, so an older render cannot overwrite a newer one.
|
|
97
|
+
let refreshes = Promise.resolve();
|
|
98
|
+
return {
|
|
99
|
+
pageToken(request) {
|
|
100
|
+
return !options.exposed && hostAllowed(request.headers.host, loopbackHosts(options.port()))
|
|
101
|
+
? token
|
|
102
|
+
: undefined;
|
|
103
|
+
},
|
|
104
|
+
async handle(request, response) {
|
|
105
|
+
if (request.method !== "POST")
|
|
106
|
+
return refuse(response, 405, "HUMANISH_NOTES_METHOD", "Notes are added with POST.");
|
|
107
|
+
if (options.exposed)
|
|
108
|
+
return refuse(response, 403, "HUMANISH_NOTES_EXPOSED", `This Observer is shared beyond this machine, so it does not take notes. Add notes from an Observer served on 127.0.0.1, or with \`${cli("notes <run> --add")}\`.`);
|
|
109
|
+
const host = request.headers.host?.trim().toLowerCase();
|
|
110
|
+
if (host === undefined ||
|
|
111
|
+
!hostAllowed(host, loopbackHosts(options.port())) ||
|
|
112
|
+
request.headers.origin !== `http://${host}`)
|
|
113
|
+
return refuse(response, 403, "HUMANISH_NOTES_ORIGIN", "A note can only come from the Observer page this server rendered, at the address it was opened from. Open the Observer from the address humanish printed and add the note there.");
|
|
114
|
+
if (!sameToken(request.headers[TOKEN_HEADER], token))
|
|
115
|
+
return refuse(response, 403, "HUMANISH_NOTES_TOKEN", "The request does not carry this server's note token, which changes each time the server starts. Reload the Observer page and add the note again.");
|
|
116
|
+
if (!/^application\/json(?:\s*;|$)/i.test(request.headers["content-type"] ?? ""))
|
|
117
|
+
return refuse(response, 415, "HUMANISH_NOTES_BODY", "A note is sent as JSON with runId, atMs, participant and text.");
|
|
118
|
+
const tooLarge = () => refuse(response, 413, "HUMANISH_NOTES_TOO_LARGE", `The request is larger than ${MAX_BODY_BYTES / 1024} KiB. A note holds at most ${MAX_NOTE_TEXT} characters.`);
|
|
119
|
+
if (Number(request.headers["content-length"] ?? 0) > MAX_BODY_BYTES)
|
|
120
|
+
return tooLarge();
|
|
121
|
+
const body = await readBody(request);
|
|
122
|
+
if (body === null)
|
|
123
|
+
return tooLarge();
|
|
124
|
+
const input = noteInput(body);
|
|
125
|
+
if (input === null)
|
|
126
|
+
return refuse(response, 400, "HUMANISH_NOTES_BODY", "A note is a JSON object with runId, atMs (milliseconds on the run clock), participant (a stream id or null) and text.");
|
|
127
|
+
const prepared = await options.resolveRun(input.runId).catch(() => null);
|
|
128
|
+
if (prepared === null)
|
|
129
|
+
return refuse(response, 404, "HUMANISH_RUN_NOT_FOUND", "This server does not serve that run, so it cannot take a note for it.");
|
|
130
|
+
const added = await addRunNote(prepared, input);
|
|
131
|
+
if (!added.ok)
|
|
132
|
+
return refuse(response, STORE_STATUS[added.error.code], added.error.code, added.error.message);
|
|
133
|
+
reply(response, 201, added);
|
|
134
|
+
refreshes = refreshes.then(() => options.refresh(prepared).then(() => undefined, () => undefined));
|
|
135
|
+
},
|
|
136
|
+
settled() {
|
|
137
|
+
return refreshes;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -14,7 +14,14 @@ export interface PinnedDirectory {
|
|
|
14
14
|
/** When set, a file's bytes are served only if this accepts their sha256. */
|
|
15
15
|
readonly admitsContent?: (relativePath: string, sha256: string) => boolean;
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
|
|
19
|
+
* file larger than that is refused from its opened size and the read stops there, so a large file
|
|
20
|
+
* is never held in memory.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readContainedFile(root: PinnedDirectory, filePathInput: string, options?: {
|
|
23
|
+
maxBytes?: number;
|
|
24
|
+
}): Promise<Buffer | null>;
|
|
18
25
|
export declare function openContainedFile(root: PinnedDirectory, filePathInput: string): Promise<{
|
|
19
26
|
handle: FileHandle;
|
|
20
27
|
size: number;
|
|
@@ -6,12 +6,21 @@ import { constants as fsConstants } from "node:fs";
|
|
|
6
6
|
import { lstat, open, realpath } from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { isPathInside, isSafeRunIdSegment } from "../run/paths.js";
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
|
|
11
|
+
* file larger than that is refused from its opened size and the read stops there, so a large file
|
|
12
|
+
* is never held in memory.
|
|
13
|
+
*/
|
|
14
|
+
export async function readContainedFile(root, filePathInput, options = {}) {
|
|
10
15
|
const opened = await openContainedFile(root, filePathInput);
|
|
11
16
|
if (!opened)
|
|
12
17
|
return null;
|
|
13
18
|
try {
|
|
14
|
-
const body =
|
|
19
|
+
const body = options.maxBytes === undefined
|
|
20
|
+
? await opened.handle.readFile()
|
|
21
|
+
: await readAtMost(opened.handle, opened.size, options.maxBytes);
|
|
22
|
+
if (body === null)
|
|
23
|
+
return null;
|
|
15
24
|
// A write that landed during the read changed the file's mtime and ctime.
|
|
16
25
|
if (root.admitsFile !== undefined &&
|
|
17
26
|
!root.admitsFile(relativeToRoot(root, filePathInput), await opened.handle.stat({ bigint: true }))) {
|
|
@@ -41,7 +50,9 @@ export async function openContainedFile(root, filePathInput) {
|
|
|
41
50
|
const expectedStats = await inspectContainedRegularFile(root, filePath);
|
|
42
51
|
if (!expectedStats)
|
|
43
52
|
return null;
|
|
44
|
-
|
|
53
|
+
// O_NONBLOCK: a leaf swapped for a FIFO after the checks above opens at once, without waiting
|
|
54
|
+
// for a writer, and the descriptor checks below refuse it.
|
|
55
|
+
handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
45
56
|
const openedStats = await handle.stat({ bigint: true });
|
|
46
57
|
if (!openedStats.isFile() ||
|
|
47
58
|
openedStats.nlink !== 1n ||
|
|
@@ -68,6 +79,24 @@ export async function openContainedFile(root, filePathInput) {
|
|
|
68
79
|
return null;
|
|
69
80
|
}
|
|
70
81
|
}
|
|
82
|
+
/** At most `maxBytes` from the start of an opened file, or null when it holds more. */
|
|
83
|
+
async function readAtMost(handle, size, maxBytes) {
|
|
84
|
+
if (size > maxBytes)
|
|
85
|
+
return null;
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let total = 0;
|
|
88
|
+
for (;;) {
|
|
89
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total));
|
|
90
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
|
|
91
|
+
if (bytesRead === 0)
|
|
92
|
+
return Buffer.concat(chunks, total);
|
|
93
|
+
total += bytesRead;
|
|
94
|
+
// The file grew past the limit after it was opened.
|
|
95
|
+
if (total > maxBytes)
|
|
96
|
+
return null;
|
|
97
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
71
100
|
/** The sha256 of an opened file, read by position so the handle's own offset is untouched. */
|
|
72
101
|
export async function sha256OfOpenedFile(handle) {
|
|
73
102
|
const hash = createHash("sha256");
|
package/dist/observer/render.js
CHANGED
|
@@ -12,12 +12,14 @@ import { loadAnalysis } from "../analysis/load.js";
|
|
|
12
12
|
import { physicalCwdOf, runIdOf, bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
|
|
13
13
|
import { writeContainedOutputFile } from "../run/contained-output.js";
|
|
14
14
|
import { loadRunBundlePrepared, readRunJsonIfExists } from "../run/locate.js";
|
|
15
|
+
import { readRunNotes } from "../run/notes.js";
|
|
15
16
|
import { RUN_STATUS_FILE } from "../run/status.js";
|
|
16
17
|
import { verifyRunPrepared } from "../verify/verify.js";
|
|
17
18
|
import { renderObserverHtml } from "./artifact.js";
|
|
18
19
|
import { buildObserverData } from "./data.js";
|
|
19
20
|
import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin } from "./http.js";
|
|
20
21
|
import { listenOnLoopback } from "./listen.js";
|
|
22
|
+
import { createNotesWriter, NOTES_PATH } from "./notes-route.js";
|
|
21
23
|
import { pinDirectChildDirectory, pinDirectory, readContainedFile, } from "./pinned-files.js";
|
|
22
24
|
import { buildHistoryIndex, matchRunRoute, serveRunPath, writeResponse, } from "./run-routes.js";
|
|
23
25
|
import { cli } from "../cli/invocation.js";
|
|
@@ -114,7 +116,7 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
|
|
|
114
116
|
};
|
|
115
117
|
await writeContainedOutputFile(preparedRunPaths, path.join("observer", "observer-data.json"), `${JSON.stringify(observerData, null, 2)}\n`, "utf8");
|
|
116
118
|
await writeContainedOutputFile(preparedRunPaths, path.join("observer", "study-analysis.json"), `${JSON.stringify(analysis, null, 2)}\n`, "utf8");
|
|
117
|
-
await writeContainedOutputFile(preparedRunPaths, path.join("observer", "index.html"), renderObserverHtml(observerData, { analysis }), "utf8");
|
|
119
|
+
await writeContainedOutputFile(preparedRunPaths, path.join("observer", "index.html"), renderObserverHtml(observerData, { analysis, notes: await readRunNotes(preparedRunPaths) }), "utf8");
|
|
118
120
|
await validatePreparedRunArtifactPaths(preparedRunPaths);
|
|
119
121
|
const relativeObserverPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "index.html");
|
|
120
122
|
const relativeObserverDataPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "observer-data.json");
|
|
@@ -180,6 +182,16 @@ async function resolveObserverRunSelection(cwd, runInput) {
|
|
|
180
182
|
return null;
|
|
181
183
|
return { runId: pointer.runId, runRoot, runsRoot };
|
|
182
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* The served run history. Exposed watch and selected-run viewers pass `onlyRun` and cannot
|
|
187
|
+
* enumerate other runs; full-library loopback viewers get the complete project index.
|
|
188
|
+
*/
|
|
189
|
+
async function writeHistory(response, proofRoot, onlyRun) {
|
|
190
|
+
const history = await buildHistoryIndex(proofRoot);
|
|
191
|
+
const runs = onlyRun === null ? history.runs : history.runs.filter((run) => run.runId === onlyRun);
|
|
192
|
+
const listed = onlyRun === null ? history : { latestRunId: runs.length > 0 ? onlyRun : null, runs };
|
|
193
|
+
writeResponse(response, 200, JSON.stringify(listed, null, 2), "application/json; charset=utf-8");
|
|
194
|
+
}
|
|
183
195
|
export async function serveObserver(result, options = {}) {
|
|
184
196
|
if (!result.ok || !result.observerPath) {
|
|
185
197
|
throw new Error("Cannot serve an observer result that did not render successfully.");
|
|
@@ -206,6 +218,14 @@ export async function serveObserver(result, options = {}) {
|
|
|
206
218
|
const runtimeStreamUrls = () => observerRuntimeStreamUrls.get(result) ?? [];
|
|
207
219
|
const exposed = options.exposed === true;
|
|
208
220
|
const scopedToRun = exposed || options.scope === "run";
|
|
221
|
+
let port = 0;
|
|
222
|
+
// Notes are taken for the selected run only, whose paths this server already holds.
|
|
223
|
+
const notes = createNotesWriter({
|
|
224
|
+
exposed,
|
|
225
|
+
port: () => port,
|
|
226
|
+
resolveRun: async (runId) => (runId === result.run ? preparedRunPaths : null),
|
|
227
|
+
refresh: (prepared) => renderObserver(cwd, result.run, { open: false, expectedRun: prepared }),
|
|
228
|
+
});
|
|
209
229
|
// Host allowlist for exposed mode (DNS-rebinding defense, identical to the run-library surface).
|
|
210
230
|
// Seeded with the loopback names after bind; addPublicOrigin extends it with the tunnel/public-url
|
|
211
231
|
// host. Never consulted in loopback (non-exposed) mode.
|
|
@@ -224,21 +244,22 @@ export async function serveObserver(result, options = {}) {
|
|
|
224
244
|
}
|
|
225
245
|
}
|
|
226
246
|
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`);
|
|
247
|
+
if (url.pathname === NOTES_PATH) {
|
|
248
|
+
await notes.handle(request, response);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Adding a note is the only write; nothing else is read with another method.
|
|
252
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
253
|
+
writeResponse(response, 405, "Method Not Allowed", "text/plain; charset=utf-8");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
227
256
|
if (url.pathname === "/") {
|
|
228
257
|
response.writeHead(302, { location: "/observer/index.html" });
|
|
229
258
|
response.end();
|
|
230
259
|
return;
|
|
231
260
|
}
|
|
232
261
|
if (url.pathname === "/_humanish/history.json") {
|
|
233
|
-
|
|
234
|
-
// Exposed watch and selected-run viewers cannot enumerate other runs. Full-library
|
|
235
|
-
// loopback viewers retain the complete project index.
|
|
236
|
-
if (scopedToRun) {
|
|
237
|
-
const attachedRuns = history.runs.filter((entry) => entry.runId === result.run);
|
|
238
|
-
writeResponse(response, 200, JSON.stringify({ latestRunId: attachedRuns.length > 0 ? result.run : null, runs: attachedRuns }, null, 2), "application/json; charset=utf-8");
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
writeResponse(response, 200, JSON.stringify(history, null, 2), "application/json; charset=utf-8");
|
|
262
|
+
await writeHistory(response, proofRoot, scopedToRun ? result.run : null);
|
|
242
263
|
return;
|
|
243
264
|
}
|
|
244
265
|
if (url.pathname.startsWith("/_humanish/runs/")) {
|
|
@@ -257,10 +278,10 @@ export async function serveObserver(result, options = {}) {
|
|
|
257
278
|
writeResponse(response, 404, "Run not found", "text/plain; charset=utf-8");
|
|
258
279
|
return;
|
|
259
280
|
}
|
|
260
|
-
await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, runRoute.runId === result.run ? runtimeStreamUrls() : [], request);
|
|
281
|
+
await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, runRoute.runId === result.run ? runtimeStreamUrls() : [], request, runRoute.runId === result.run ? notes.pageToken(request) : undefined);
|
|
261
282
|
return;
|
|
262
283
|
}
|
|
263
|
-
await serveRunPath(runRoot, decodeURIComponent(url.pathname.slice(1)), response, runtimeStreamUrls(), request);
|
|
284
|
+
await serveRunPath(runRoot, decodeURIComponent(url.pathname.slice(1)), response, runtimeStreamUrls(), request, notes.pageToken(request));
|
|
264
285
|
}
|
|
265
286
|
catch {
|
|
266
287
|
writeResponse(response, 500, "Observer request failed", "text/plain; charset=utf-8");
|
|
@@ -269,7 +290,7 @@ export async function serveObserver(result, options = {}) {
|
|
|
269
290
|
const server = createServer((request, response) => {
|
|
270
291
|
void handleRequest(request, response);
|
|
271
292
|
});
|
|
272
|
-
|
|
293
|
+
port = await listenOnLoopback(server, options.port ?? 0);
|
|
273
294
|
if (exposed) {
|
|
274
295
|
hostAllowlist.add(`127.0.0.1:${port}`);
|
|
275
296
|
hostAllowlist.add(`localhost:${port}`);
|
|
@@ -295,7 +316,10 @@ export async function serveObserver(result, options = {}) {
|
|
|
295
316
|
hostAllowlist.add(parsed.host);
|
|
296
317
|
}
|
|
297
318
|
},
|
|
298
|
-
close: () =>
|
|
319
|
+
close: async () => {
|
|
320
|
+
await notes.settled();
|
|
321
|
+
await closeServer(server);
|
|
322
|
+
},
|
|
299
323
|
};
|
|
300
324
|
}
|
|
301
325
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { type PreparedRunArtifactPaths } from "../run/paths.js";
|
|
2
3
|
import type { RunDisplay } from "../run/display.js";
|
|
3
4
|
import { type ObserverData } from "./data.js";
|
|
4
5
|
import { type PinnedDirectory } from "./pinned-files.js";
|
|
@@ -10,8 +11,13 @@ export interface ObserverRuntimeStreamUrl {
|
|
|
10
11
|
* evidence (keyframe replay/screenshot) instead of rendering "sandbox not found". */
|
|
11
12
|
ended?: boolean;
|
|
12
13
|
}
|
|
13
|
-
/**
|
|
14
|
-
export declare function
|
|
14
|
+
/** The run's artifact paths, only when they are still the pinned directory being served. */
|
|
15
|
+
export declare function servedRunPaths(runRoot: PinnedDirectory): Promise<PreparedRunArtifactPaths>;
|
|
16
|
+
/**
|
|
17
|
+
* internal: consumed by src/observer/serve.ts. `notesToken` goes into the run's Observer page so
|
|
18
|
+
* it can add notes; a server passes it only for a run it takes notes for.
|
|
19
|
+
*/
|
|
20
|
+
export declare function serveRunPath(runRoot: PinnedDirectory, relativePath: string, response: ServerResponse, runtimeStreamUrls?: ObserverRuntimeStreamUrl[], request?: Pick<IncomingMessage, "method" | "headers">, notesToken?: string): Promise<void>;
|
|
15
21
|
/** internal: exported for the stream lifecycle tests. */
|
|
16
22
|
export declare function withRuntimeStreamUrls(data: ObserverData, runtimeStreamUrls: ObserverRuntimeStreamUrl[]): ObserverData;
|
|
17
23
|
/** internal: consumed by src/observer/serve.ts */
|