humanish 0.0.1 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +77 -0
- package/CONTRIBUTING.md +39 -0
- package/LICENSE +21 -0
- package/README.md +437 -8
- package/SECURITY.md +34 -0
- package/dist/actor-contract.d.ts +107 -0
- package/dist/actor-contract.js +286 -0
- package/dist/actor-contract.js.map +1 -0
- package/dist/actor-registry.d.ts +73 -0
- package/dist/actor-registry.js +93 -0
- package/dist/actor-registry.js.map +1 -0
- package/dist/adapter-extension.d.ts +54 -0
- package/dist/adapter-extension.js +214 -0
- package/dist/adapter-extension.js.map +1 -0
- package/dist/argv.d.ts +1 -0
- package/dist/argv.js +8 -0
- package/dist/argv.js.map +1 -0
- package/dist/artifact-reference.d.ts +22 -0
- package/dist/artifact-reference.js +47 -0
- package/dist/artifact-reference.js.map +1 -0
- package/dist/browser-evidence-hygiene.d.ts +7 -0
- package/dist/browser-evidence-hygiene.js +40 -0
- package/dist/browser-evidence-hygiene.js.map +1 -0
- package/dist/claude-agent-sdk.d.ts +94 -0
- package/dist/claude-agent-sdk.js +363 -0
- package/dist/claude-agent-sdk.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -0
- package/dist/codex-app-server-ui.d.ts +39 -0
- package/dist/codex-app-server-ui.js +357 -0
- package/dist/codex-app-server-ui.js.map +1 -0
- package/dist/codex-app-server.d.ts +144 -0
- package/dist/codex-app-server.js +754 -0
- package/dist/codex-app-server.js.map +1 -0
- package/dist/command-failure.d.ts +29 -0
- package/dist/command-failure.js +54 -0
- package/dist/command-failure.js.map +1 -0
- package/dist/computer-use-actor.d.ts +52 -0
- package/dist/computer-use-actor.js +48 -0
- package/dist/computer-use-actor.js.map +1 -0
- package/dist/computer-use.d.ts +232 -0
- package/dist/computer-use.js +615 -0
- package/dist/computer-use.js.map +1 -0
- package/dist/concurrency.d.ts +1 -0
- package/dist/concurrency.js +27 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/concurrent-shared-world-lab.d.ts +115 -0
- package/dist/concurrent-shared-world-lab.js +1094 -0
- package/dist/concurrent-shared-world-lab.js.map +1 -0
- package/dist/core/git-state.d.ts +33 -0
- package/dist/core/git-state.js +277 -0
- package/dist/core/git-state.js.map +1 -0
- package/dist/core/git-workspace.d.ts +31 -0
- package/dist/core/git-workspace.js +447 -0
- package/dist/core/git-workspace.js.map +1 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/run-primitives.d.ts +66 -0
- package/dist/core/run-primitives.js +120 -0
- package/dist/core/run-primitives.js.map +1 -0
- package/dist/cua-actor-lab.d.ts +621 -0
- package/dist/cua-actor-lab.js +2845 -0
- package/dist/cua-actor-lab.js.map +1 -0
- package/dist/device-presets.d.ts +67 -0
- package/dist/device-presets.js +50 -0
- package/dist/device-presets.js.map +1 -0
- package/dist/e2b-desktop-executor.d.ts +101 -0
- package/dist/e2b-desktop-executor.js +309 -0
- package/dist/e2b-desktop-executor.js.map +1 -0
- package/dist/e2b-desktop-launch.d.ts +144 -0
- package/dist/e2b-desktop-launch.js +59 -0
- package/dist/e2b-desktop-launch.js.map +1 -0
- package/dist/e2b-detached.d.ts +53 -0
- package/dist/e2b-detached.js +130 -0
- package/dist/e2b-detached.js.map +1 -0
- package/dist/e2b-terminal-lab.d.ts +345 -0
- package/dist/e2b-terminal-lab.js +1532 -0
- package/dist/e2b-terminal-lab.js.map +1 -0
- package/dist/env-file.d.ts +14 -0
- package/dist/env-file.js +108 -0
- package/dist/env-file.js.map +1 -0
- package/dist/feedback.d.ts +51 -0
- package/dist/feedback.js +351 -0
- package/dist/feedback.js.map +1 -0
- package/dist/image-evidence.d.ts +2 -0
- package/dist/image-evidence.js +33 -0
- package/dist/image-evidence.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/init-templates.d.ts +12 -0
- package/dist/init-templates.js +369 -0
- package/dist/init-templates.js.map +1 -0
- package/dist/init.d.ts +26 -0
- package/dist/init.js +429 -0
- package/dist/init.js.map +1 -0
- package/dist/lab-app-runner.d.ts +78 -0
- package/dist/lab-app-runner.js +403 -0
- package/dist/lab-app-runner.js.map +1 -0
- package/dist/lab-config.d.ts +583 -0
- package/dist/lab-config.js +1789 -0
- package/dist/lab-config.js.map +1 -0
- package/dist/lab-engine.d.ts +77 -0
- package/dist/lab-engine.js +216 -0
- package/dist/lab-engine.js.map +1 -0
- package/dist/lab-preflight.d.ts +67 -0
- package/dist/lab-preflight.js +385 -0
- package/dist/lab-preflight.js.map +1 -0
- package/dist/labs.d.ts +53 -0
- package/dist/labs.js +382 -0
- package/dist/labs.js.map +1 -0
- package/dist/observer-assets.d.ts +2 -0
- package/dist/observer-assets.js +2630 -0
- package/dist/observer-assets.js.map +1 -0
- package/dist/observer-data.d.ts +63 -0
- package/dist/observer-data.js +145 -0
- package/dist/observer-data.js.map +1 -0
- package/dist/observer-static.d.ts +39 -0
- package/dist/observer-static.js +306 -0
- package/dist/observer-static.js.map +1 -0
- package/dist/observer.d.ts +46 -0
- package/dist/observer.js +550 -0
- package/dist/observer.js.map +1 -0
- package/dist/openai-responses-cu.d.ts +113 -0
- package/dist/openai-responses-cu.js +545 -0
- package/dist/openai-responses-cu.js.map +1 -0
- package/dist/oss-lab.d.ts +52 -0
- package/dist/oss-lab.js +344 -0
- package/dist/oss-lab.js.map +1 -0
- package/dist/oss-meta-lab-scoring.d.ts +15 -0
- package/dist/oss-meta-lab-scoring.js +145 -0
- package/dist/oss-meta-lab-scoring.js.map +1 -0
- package/dist/oss-meta-lab.d.ts +277 -0
- package/dist/oss-meta-lab.js +4529 -0
- package/dist/oss-meta-lab.js.map +1 -0
- package/dist/oss-remote-telemetry.d.ts +77 -0
- package/dist/oss-remote-telemetry.js +403 -0
- package/dist/oss-remote-telemetry.js.map +1 -0
- package/dist/persona.d.ts +35 -0
- package/dist/persona.js +133 -0
- package/dist/persona.js.map +1 -0
- package/dist/pi-agent-core.d.ts +77 -0
- package/dist/pi-agent-core.js +203 -0
- package/dist/pi-agent-core.js.map +1 -0
- package/dist/program.d.ts +54 -0
- package/dist/program.js +2349 -0
- package/dist/program.js.map +1 -0
- package/dist/redaction.d.ts +90 -0
- package/dist/redaction.js +292 -0
- package/dist/redaction.js.map +1 -0
- package/dist/run-paths.d.ts +56 -0
- package/dist/run-paths.js +340 -0
- package/dist/run-paths.js.map +1 -0
- package/dist/run.d.ts +794 -0
- package/dist/run.js +4844 -0
- package/dist/run.js.map +1 -0
- package/dist/scripted-browser-actor.d.ts +297 -0
- package/dist/scripted-browser-actor.js +1399 -0
- package/dist/scripted-browser-actor.js.map +1 -0
- package/dist/scripted-browser-lab.d.ts +107 -0
- package/dist/scripted-browser-lab.js +788 -0
- package/dist/scripted-browser-lab.js.map +1 -0
- package/dist/selected-output-paths.d.ts +49 -0
- package/dist/selected-output-paths.js +354 -0
- package/dist/selected-output-paths.js.map +1 -0
- package/dist/shared-world-lab.d.ts +208 -0
- package/dist/shared-world-lab.js +1049 -0
- package/dist/shared-world-lab.js.map +1 -0
- package/dist/source-archive.d.ts +82 -0
- package/dist/source-archive.js +408 -0
- package/dist/source-archive.js.map +1 -0
- package/dist/stop-conditions.d.ts +26 -0
- package/dist/stop-conditions.js +66 -0
- package/dist/stop-conditions.js.map +1 -0
- package/dist/terminal-agent-actor.d.ts +36 -0
- package/dist/terminal-agent-actor.js +23 -0
- package/dist/terminal-agent-actor.js.map +1 -0
- package/docs/architecture/actor-contract.md +431 -0
- package/docs/architecture/github-feedback-loop.md +189 -0
- package/docs/architecture/local-codex-tui-actor.md +213 -0
- package/docs/architecture/observer.md +118 -0
- package/docs/architecture/oss-lab-poc.md +250 -0
- package/docs/architecture/project-layout.md +166 -0
- package/docs/architecture/state-driven-executor.md +203 -0
- package/docs/architecture/terminal-product-lane.md +148 -0
- package/docs/contracts/adapter-fixtures.md +80 -0
- package/docs/contracts/core.md +71 -0
- package/docs/contracts/feedback.md +145 -0
- package/docs/contracts/policy.md +311 -0
- package/docs/contracts/run-bundle.md +358 -0
- package/docs/contracts/schemas.md +984 -0
- package/docs/goals/current.md +428 -0
- package/docs/principles/invariants-and-defaults.md +135 -0
- package/docs/principles/self-driving-harness.md +129 -0
- package/docs/product/open-source-install-experience.md +237 -0
- package/docs/ramp/README.md +207 -0
- package/docs/release/open-source-readiness.md +208 -0
- package/docs/release/public-readiness-standard.md +205 -0
- package/docs/roadmap/world-class-open-source-v0.md +311 -0
- package/package.json +102 -8
- package/skills/humanish/SKILL.md +244 -0
- package/skills/humanish/agents/openai.yaml +7 -0
- package/index.js +0 -1
|
@@ -0,0 +1,2845 @@
|
|
|
1
|
+
// The computer-use lab backend: a subject (an app-url the caller provisioned, or a repo the
|
|
2
|
+
// lab clones AND serves in-sandbox) driven by a REGISTRY-RESOLVED computer-use actor inside a
|
|
3
|
+
// hosted E2B desktop. This is the path that makes `actors[].type` load-bearing — the
|
|
4
|
+
// descriptor returned by the registry runs the session; the lab provisions the desktop and
|
|
5
|
+
// subject, composes the prompt from config, persists the evidence bundle, and tears down.
|
|
6
|
+
//
|
|
7
|
+
// Substrate notes:
|
|
8
|
+
// - The desktop is created via the shared loader in e2b-desktop-launch.ts with kill-on-timeout
|
|
9
|
+
// lifecycle, so a dead host process can never orphan a sandbox past its server-side deadline.
|
|
10
|
+
// - Env placement follows the doctrine (docs/principles/invariants-and-defaults.md): the
|
|
11
|
+
// ACTOR's key never enters the sandbox (the model drives from outside via the provider API);
|
|
12
|
+
// the SUBJECT's declared env NAMES are provisioned in on the clone route — values come from
|
|
13
|
+
// the caller's environment and are never logged or persisted.
|
|
14
|
+
// - The live stream URL is runtime-only (carries an auth key) and is never persisted into run
|
|
15
|
+
// artifacts — only its presence is recorded, mirroring the meta lab's convention.
|
|
16
|
+
// - Evidence redaction is mode-aware (docs/principles/invariants-and-defaults.md, the
|
|
17
|
+
// capture-vs-publish rule): screenshots persist RAW (full fidelity) by default into gitignored
|
|
18
|
+
// .humanish/; `policies.redactScreenshots: true` opts into blur-at-capture for a share-as-is
|
|
19
|
+
// bundle. Length-only typed text and text redaction of reasoning/messages are UNCONDITIONAL;
|
|
20
|
+
// harness errors are redacted at THIS boundary; the bundle's `stream.actor` carries the
|
|
21
|
+
// conformant humanish.actor-trace.v1 projection, whose `redaction.screenshots` records the
|
|
22
|
+
// run's actual mode ("raw" | "blurred" | "n/a") — every label downstream derives from it.
|
|
23
|
+
import { randomBytes } from "node:crypto";
|
|
24
|
+
import { readFile, realpath, rm } from "node:fs/promises";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { runDesktopCommandOrThrow, toErrorMessage } from "./command-failure.js";
|
|
27
|
+
import { pathToFileURL } from "node:url";
|
|
28
|
+
import { adapterScoreFailureMessage, applyBrowserAdapterHooks } from "./adapter-extension.js";
|
|
29
|
+
import { actorRegistry, isCuaActorDescriptor } from "./actor-registry.js";
|
|
30
|
+
import { CHROMIUM_EVIDENCE_HYGIENE_FLAGS, chromiumEvidenceProfilePreferencesJson } from "./browser-evidence-hygiene.js";
|
|
31
|
+
import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
|
|
32
|
+
import { probeUrl, readDetachedLog, runDetachedStep, startDetachedProcess } from "./e2b-detached.js";
|
|
33
|
+
import { DEFAULT_DEVICE_PRESET, isDevicePresetName, resolveDevicePreset } from "./device-presets.js";
|
|
34
|
+
import { cuaLaneValidationReason, isHttpUrl, isLoopbackUrl, MAX_CUA_LANES, subjectStateInvalidReason } from "./lab-config.js";
|
|
35
|
+
import { mapWithConcurrency } from "./concurrency.js";
|
|
36
|
+
import { assertScreenshotEvidence } from "./image-evidence.js";
|
|
37
|
+
import { buildObserverData } from "./observer-data.js";
|
|
38
|
+
import { attachObserverRuntimeStreamUrls, renderObserver } from "./observer.js";
|
|
39
|
+
import { containsSensitive, digestText, redactedTail, redactText } from "./redaction.js";
|
|
40
|
+
import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, prepareContainedOutputDirectory, prepareSelectedOutputDirectory, writeContainedOutputFile, writePreparedRunLatestPointer } from "./selected-output-paths.js";
|
|
41
|
+
import { prepareRunArtifactPaths, validatePreparedRunArtifactPaths } from "./run-paths.js";
|
|
42
|
+
import { createLocalTreeArchive } from "./source-archive.js";
|
|
43
|
+
import { buildRunSource, loadRunBundle, PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA } from "./run.js";
|
|
44
|
+
export const CUA_ACTOR_LAB_SCHEMA = "humanish.cua-lab-result.v2";
|
|
45
|
+
// The only fan-out topology this slice ships: N lanes = N independent E2B desktop sandboxes,
|
|
46
|
+
// each its own world (clone/serve + subject.state per lane). Shared-world is layer 7 (#164).
|
|
47
|
+
export const CUA_FANOUT_STRATEGY = "per-lane-worlds";
|
|
48
|
+
// Default in-flight lane bound when the config does not declare execution.concurrency.
|
|
49
|
+
const DEFAULT_CUA_CONCURRENCY = 3;
|
|
50
|
+
// Env override that may only LOWER the effective concurrency (never raise concurrent paid
|
|
51
|
+
// desktops — invariant 3). Read names-only into a local; the value never persists.
|
|
52
|
+
const CUA_MAX_CONCURRENCY_ENV = "HUMANISH_CUA_MAX_CONCURRENCY";
|
|
53
|
+
export const CUA_ACTOR_LAB_PROVIDER_METADATA = {
|
|
54
|
+
mode: "cua-actor-lab",
|
|
55
|
+
tool: "humanish"
|
|
56
|
+
};
|
|
57
|
+
const DEFAULT_SESSION_TIMEOUT_MS = 300_000;
|
|
58
|
+
// Settle after opening the browser, before the first screenshot — long enough for a cold
|
|
59
|
+
// browser + page load to paint (2s captured a blank desktop; the render empirically needs ~6-9s).
|
|
60
|
+
const BROWSER_SETTLE_MS = 8_000;
|
|
61
|
+
// Device/viewport comes from the named-preset registry (device-presets.ts), selectable per run
|
|
62
|
+
// via execution.desktop.device (default `desktop`=1440x950). NOTE: this is run-wide for now; a
|
|
63
|
+
// per-PERSONA device dimension (N personas × devices, as the bespoke sims author) lands with
|
|
64
|
+
// fan-out. On this E2B-desktop route only width/height physically render — isMobile/DSF are
|
|
65
|
+
// honest metadata + a prompt signal, not rendered (device-presets.ts FIDELITY NOTE).
|
|
66
|
+
// Server-side reclamation buffer past the loop's own wall-clock stop.
|
|
67
|
+
const SANDBOX_TIMEOUT_BUFFER_MS = 10 * 60_000;
|
|
68
|
+
// Room the clone route adds to the sandbox deadline for clone/install/build/start/probe.
|
|
69
|
+
const SUBJECT_PROVISION_BUDGET_MS = 30 * 60_000;
|
|
70
|
+
export const SUBJECT_DIR = "/home/user/subject";
|
|
71
|
+
// Remote path for the once-per-run packed local-tree archive; removed by the extract step
|
|
72
|
+
// after it unpacks into SUBJECT_DIR.
|
|
73
|
+
const LOCAL_TREE_REMOTE_ARCHIVE_PATH = "/home/user/.humanish-source.tar.gz";
|
|
74
|
+
const CLONE_TIMEOUT_MS = 5 * 60_000;
|
|
75
|
+
const INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
76
|
+
const BUILD_TIMEOUT_MS = 10 * 60_000;
|
|
77
|
+
const DEFAULT_READY_TIMEOUT_MS = 180_000;
|
|
78
|
+
// Per-step budget for subject.state seed steps; each step's declared (or default) budget is
|
|
79
|
+
// also summed into the default sandbox deadline so seeding never eats the session's room.
|
|
80
|
+
const DEFAULT_STATE_STEP_TIMEOUT_MS = 5 * 60_000;
|
|
81
|
+
// How much of a failing step's log tail rides the (redacted) error message.
|
|
82
|
+
const ERROR_TAIL_CHARS = 2000;
|
|
83
|
+
const DEFAULT_MISSION = "You are testing a web application. The browser is already open at the subject URL. Explore it, accomplish what the scenario asks, and stop when done.";
|
|
84
|
+
/** Compose one lane's actor prompt: persona line + device line + mission + per-lane steer.
|
|
85
|
+
* At N=1 (homogeneous, no roster) this reproduces the prior composeInstructions byte-for-byte. */
|
|
86
|
+
export function composeLaneInstructions(args) {
|
|
87
|
+
const { name, preset } = args.device;
|
|
88
|
+
const deviceLine = preset.isMobile
|
|
89
|
+
? `You are a mobile user on a ${name} device (${preset.width}x${preset.height} @${preset.deviceScaleFactor}x). Expect a mobile/touch layout.`
|
|
90
|
+
: `You are a desktop user (${name}, ${preset.width}x${preset.height}).`;
|
|
91
|
+
const parts = [
|
|
92
|
+
args.persona ? `Persona: ${args.persona}.` : undefined,
|
|
93
|
+
deviceLine,
|
|
94
|
+
args.mission,
|
|
95
|
+
args.instruction ? `Lane focus: ${args.instruction}` : undefined
|
|
96
|
+
].filter((part) => Boolean(part));
|
|
97
|
+
const instructions = parts.join("\n\n");
|
|
98
|
+
return {
|
|
99
|
+
instructions,
|
|
100
|
+
persona: {
|
|
101
|
+
id: args.persona ?? "cua-operator",
|
|
102
|
+
traitsApplied: [],
|
|
103
|
+
promptDigest: digestText(instructions, 16)
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Resolve a lane's device + rendered resolution (most-specific wins, exactly as the single-lane
|
|
109
|
+
* path always has): a raw execution.desktop.resolution escape hatch (only legal when no lane
|
|
110
|
+
* sets a device — XOR enforced at parse) → the lane's named device → the run-wide
|
|
111
|
+
* execution.desktop.device → the default preset. A raw resolution is an unnamed custom desktop
|
|
112
|
+
* (non-mobile, DSF 1): we never claim a named preset's mobile/DPR for hand-set geometry.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveLaneDevice(config, lane) {
|
|
115
|
+
const rawResolution = config.execution?.desktop?.resolution;
|
|
116
|
+
if (lane?.device === undefined && rawResolution) {
|
|
117
|
+
const preset = { width: rawResolution[0], height: rawResolution[1], isMobile: false, deviceScaleFactor: 1 };
|
|
118
|
+
return { name: "custom", preset, resolution: [rawResolution[0], rawResolution[1]] };
|
|
119
|
+
}
|
|
120
|
+
const candidate = lane?.device ?? config.execution?.desktop?.device;
|
|
121
|
+
const presetName = isDevicePresetName(candidate) ? candidate : DEFAULT_DEVICE_PRESET;
|
|
122
|
+
const preset = resolveDevicePreset(presetName);
|
|
123
|
+
return { name: presetName, preset, resolution: [preset.width, preset.height] };
|
|
124
|
+
}
|
|
125
|
+
/** Per-lane sandbox deadline (each lane owns its own desktop). Mirrors the single-lane formula
|
|
126
|
+
* verbatim so N=1 stays byte-stable: explicit sandboxTimeoutMs, else session budget + (clone
|
|
127
|
+
* or local-tree: provision budget + Σ state-step budgets) + the server-side
|
|
128
|
+
* reclamation buffer. Local-tree shares the clone route's provisioning budget: it swaps a
|
|
129
|
+
* git clone for an upload+extract, but the shared install/build/state/start/probe pipeline
|
|
130
|
+
* costs the same wall-clock room either way. */
|
|
131
|
+
function resolvePerLaneSandboxMs(config) {
|
|
132
|
+
const timeoutMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
|
|
133
|
+
const provisionedRoute = config.subject.source === "clone" || config.subject.source === "local-tree";
|
|
134
|
+
const stateBudgetMs = provisionedRoute
|
|
135
|
+
? (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0)
|
|
136
|
+
: 0;
|
|
137
|
+
return config.execution?.desktop?.sandboxTimeoutMs
|
|
138
|
+
?? timeoutMs + (provisionedRoute ? SUBJECT_PROVISION_BUDGET_MS + stateBudgetMs : 0) + SANDBOX_TIMEOUT_BUFFER_MS;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Effective in-flight lane bound. Default min(laneCount, 3); a declared execution.concurrency
|
|
142
|
+
* clamps to [1, laneCount]; the env override may only LOWER it (never raise concurrent paid
|
|
143
|
+
* desktops — invariant 3). Pure given (config, laneCount, env).
|
|
144
|
+
*/
|
|
145
|
+
function resolveCuaConcurrency(config, laneCount, env) {
|
|
146
|
+
const declared = config.execution?.concurrency;
|
|
147
|
+
const base = declared !== undefined
|
|
148
|
+
? Math.min(Math.max(1, declared), laneCount)
|
|
149
|
+
: Math.min(laneCount, DEFAULT_CUA_CONCURRENCY);
|
|
150
|
+
const envLower = readPositiveInt(env[CUA_MAX_CONCURRENCY_ENV], 0);
|
|
151
|
+
if (envLower > 0) {
|
|
152
|
+
return Math.max(1, Math.min(base, envLower, laneCount));
|
|
153
|
+
}
|
|
154
|
+
return Math.max(1, base);
|
|
155
|
+
}
|
|
156
|
+
/** Build the lane specs AND the public plan from a config (pure). countOverride is the CLI
|
|
157
|
+
* --count for homogeneous fan-out (ignored when a `lanes` roster is declared). */
|
|
158
|
+
function laneSpecsAndPlan(config, opts = {}) {
|
|
159
|
+
const env = opts.env ?? {};
|
|
160
|
+
const actor = config.actors[0];
|
|
161
|
+
const mission = actor?.mission ?? DEFAULT_MISSION;
|
|
162
|
+
const roster = actor?.lanes;
|
|
163
|
+
const laneCount = roster ? roster.length : Math.max(1, opts.countOverride ?? actor?.count ?? 1);
|
|
164
|
+
const lanes = [];
|
|
165
|
+
for (let i = 0; i < laneCount; i += 1) {
|
|
166
|
+
const lane = roster?.[i];
|
|
167
|
+
const laneId = lane?.id ?? `lane-${String(i + 1).padStart(2, "0")}`;
|
|
168
|
+
const simId = `sim-${String(i + 1).padStart(3, "0")}`;
|
|
169
|
+
const streamId = `stream-${String(i + 1).padStart(3, "0")}`;
|
|
170
|
+
const device = resolveLaneDevice(config, lane);
|
|
171
|
+
const composed = composeLaneInstructions({
|
|
172
|
+
mission,
|
|
173
|
+
...(((roster ? lane?.persona : actor?.persona)) === undefined ? {} : { persona: (roster ? lane?.persona : actor?.persona) }),
|
|
174
|
+
...(((roster ? lane?.instruction : actor?.laneFocus?.instruction)) === undefined ? {} : { instruction: (roster ? lane?.instruction : actor?.laneFocus?.instruction) }),
|
|
175
|
+
device: { name: device.name, preset: device.preset }
|
|
176
|
+
});
|
|
177
|
+
lanes.push({
|
|
178
|
+
laneId,
|
|
179
|
+
...(lane?.actorType === undefined ? {} : { actorType: lane.actorType }),
|
|
180
|
+
...(lane?.surface === undefined ? {} : { surface: lane.surface }),
|
|
181
|
+
...(lane?.caseGroup === undefined ? {} : { caseGroup: lane.caseGroup }),
|
|
182
|
+
laneIndex: i,
|
|
183
|
+
simId,
|
|
184
|
+
streamId,
|
|
185
|
+
persona: composed.persona,
|
|
186
|
+
instructions: composed.instructions,
|
|
187
|
+
...(lane?.target === undefined ? {} : { targetUrl: lane.target }),
|
|
188
|
+
...((lane?.stopWhen ?? actor?.stopWhen) === undefined ? {} : { stopWhen: (lane?.stopWhen ?? actor?.stopWhen) }),
|
|
189
|
+
deviceName: device.name,
|
|
190
|
+
devicePreset: device.preset,
|
|
191
|
+
resolution: device.resolution,
|
|
192
|
+
screenshotDir: laneCount === 1 ? "" : laneId,
|
|
193
|
+
traceArtifactPath: laneCount === 1 ? "actor.json" : `actors/${streamId}.json`
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const concurrency = resolveCuaConcurrency(config, laneCount, env);
|
|
197
|
+
const perLaneSessionBudgetMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
|
|
198
|
+
const perLaneSandboxMs = resolvePerLaneSandboxMs(config);
|
|
199
|
+
const plan = {
|
|
200
|
+
strategy: CUA_FANOUT_STRATEGY,
|
|
201
|
+
laneCount,
|
|
202
|
+
concurrency,
|
|
203
|
+
waves: Math.ceil(laneCount / concurrency),
|
|
204
|
+
perLaneSessionBudgetMs,
|
|
205
|
+
worstCaseSandboxMinutes: Math.round((laneCount * perLaneSandboxMs) / 60_000),
|
|
206
|
+
dryRun: opts.dryRun === true,
|
|
207
|
+
lanes: lanes.map((spec) => ({
|
|
208
|
+
id: spec.laneId,
|
|
209
|
+
...(spec.actorType === undefined ? {} : { actorType: spec.actorType }),
|
|
210
|
+
...(spec.surface === undefined ? {} : { surface: spec.surface }),
|
|
211
|
+
...(spec.caseGroup === undefined ? {} : { caseGroup: spec.caseGroup }),
|
|
212
|
+
index: spec.laneIndex + 1,
|
|
213
|
+
persona: spec.persona.id,
|
|
214
|
+
device: spec.deviceName,
|
|
215
|
+
resolution: spec.resolution,
|
|
216
|
+
instructionDigest: spec.persona.promptDigest,
|
|
217
|
+
...(spec.targetUrl === undefined ? {} : { targetDigest: digestUrl(spec.targetUrl) })
|
|
218
|
+
}))
|
|
219
|
+
};
|
|
220
|
+
return { lanes, plan };
|
|
221
|
+
}
|
|
222
|
+
async function resolveCuaRerunSelection(args) {
|
|
223
|
+
const source = await loadRunBundle(args.cwd, args.sourceRunId);
|
|
224
|
+
if (!source) {
|
|
225
|
+
return { ok: false, message: `source run not found or invalid: ${args.sourceRunId}` };
|
|
226
|
+
}
|
|
227
|
+
const bundle = source.bundle;
|
|
228
|
+
if (bundle.mode !== "live") {
|
|
229
|
+
return { ok: false, message: `source run ${bundle.runId} is ${bundle.mode}; rerun selection only applies to live CUA fan-out evidence.` };
|
|
230
|
+
}
|
|
231
|
+
const fanoutEvent = bundle.events.some((event) => event.type === "cua-lab.fanout.plan");
|
|
232
|
+
if (!fanoutEvent || bundle.streams.length < 2) {
|
|
233
|
+
return { ok: false, message: `source run ${bundle.runId} is not a CUA fan-out run.` };
|
|
234
|
+
}
|
|
235
|
+
const prior = bundle.streams
|
|
236
|
+
.map(snapshotPriorCuaLane)
|
|
237
|
+
.filter((lane) => lane !== null);
|
|
238
|
+
const priorById = new Map(prior.map((lane) => [lane.laneId, lane]));
|
|
239
|
+
if (priorById.size < 2) {
|
|
240
|
+
return { ok: false, message: `source run ${bundle.runId} does not expose multiple lane ids.` };
|
|
241
|
+
}
|
|
242
|
+
const explicitLaneIds = uniqueLaneIds(args.laneIds ?? []);
|
|
243
|
+
const selectedLaneIds = explicitLaneIds.length > 0
|
|
244
|
+
? explicitLaneIds
|
|
245
|
+
: prior.filter((lane) => lane.rerunnable).map((lane) => lane.laneId);
|
|
246
|
+
if (selectedLaneIds.length === 0) {
|
|
247
|
+
return { ok: false, message: `source run ${bundle.runId} has no failed, blocked, timed-out, or hollow lanes to rerun.` };
|
|
248
|
+
}
|
|
249
|
+
const missingPrior = selectedLaneIds.filter((laneId) => !priorById.has(laneId));
|
|
250
|
+
if (missingPrior.length > 0) {
|
|
251
|
+
return { ok: false, message: `selected lane id(s) were not present in source run ${bundle.runId}: ${missingPrior.join(", ")}` };
|
|
252
|
+
}
|
|
253
|
+
const specsById = new Map(args.laneSpecs.map((spec) => [spec.laneId, spec]));
|
|
254
|
+
const missingCurrent = selectedLaneIds.filter((laneId) => !specsById.has(laneId));
|
|
255
|
+
if (missingCurrent.length > 0) {
|
|
256
|
+
return { ok: false, message: `selected lane id(s) are not present in the current lab config ${args.config.id}: ${missingCurrent.join(", ")}` };
|
|
257
|
+
}
|
|
258
|
+
const selectedSpecs = selectedLaneIds.map((laneId) => specsById.get(laneId));
|
|
259
|
+
const selectedPlanLaneIds = new Set(selectedLaneIds);
|
|
260
|
+
const selectedPlanEntries = args.plan.lanes.filter((lane) => selectedPlanLaneIds.has(lane.id));
|
|
261
|
+
const concurrency = Math.max(1, Math.min(args.plan.concurrency, selectedSpecs.length));
|
|
262
|
+
const plan = {
|
|
263
|
+
...args.plan,
|
|
264
|
+
laneCount: selectedSpecs.length,
|
|
265
|
+
concurrency,
|
|
266
|
+
waves: Math.ceil(selectedSpecs.length / concurrency),
|
|
267
|
+
worstCaseSandboxMinutes: Math.round((selectedSpecs.length * resolvePerLaneSandboxMs(args.config)) / 60_000),
|
|
268
|
+
lanes: selectedPlanEntries
|
|
269
|
+
};
|
|
270
|
+
const previous = selectedLaneIds.map((laneId) => priorById.get(laneId).previous);
|
|
271
|
+
return {
|
|
272
|
+
ok: true,
|
|
273
|
+
laneSpecs: selectedSpecs,
|
|
274
|
+
plan,
|
|
275
|
+
rerun: {
|
|
276
|
+
sourceRunId: bundle.runId,
|
|
277
|
+
selectedLaneIds,
|
|
278
|
+
previous
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function uniqueLaneIds(values) {
|
|
283
|
+
const seen = new Set();
|
|
284
|
+
const result = [];
|
|
285
|
+
for (const value of values) {
|
|
286
|
+
const laneId = value.trim();
|
|
287
|
+
if (!laneId || seen.has(laneId))
|
|
288
|
+
continue;
|
|
289
|
+
seen.add(laneId);
|
|
290
|
+
result.push(laneId);
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
function snapshotPriorCuaLane(stream) {
|
|
295
|
+
if (stream.kind !== "browser" || typeof stream.laneId !== "string" || !stream.laneId.trim()) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const actorStatus = stream.actor?.status;
|
|
299
|
+
const completionReason = stream.actor?.completionReason;
|
|
300
|
+
const reason = stream.ui?.state ?? stream.actor?.reason;
|
|
301
|
+
const actions = stream.actor?.counts.actions ?? 0;
|
|
302
|
+
const messages = stream.actor?.counts.messages ?? 0;
|
|
303
|
+
const hollow = completionReason === "goal_satisfied" && actions === 0 && messages === 0;
|
|
304
|
+
const rerunnable = stream.status !== "passed"
|
|
305
|
+
|| actorStatus === "failed"
|
|
306
|
+
|| actorStatus === "blocked"
|
|
307
|
+
|| actorStatus === "timed_out"
|
|
308
|
+
|| completionReason === "harness_error"
|
|
309
|
+
|| hollow;
|
|
310
|
+
return {
|
|
311
|
+
laneId: stream.laneId,
|
|
312
|
+
previous: {
|
|
313
|
+
laneId: stream.laneId,
|
|
314
|
+
streamId: stream.id,
|
|
315
|
+
status: stream.status,
|
|
316
|
+
...(reason === undefined ? {} : { reason }),
|
|
317
|
+
...(actorStatus === undefined ? {} : { actorStatus }),
|
|
318
|
+
...(completionReason === undefined ? {} : { completionReason })
|
|
319
|
+
},
|
|
320
|
+
rerunnable
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Pure pre-flight plan resolver (runs in dry-run AND live). Returns the lane table, the
|
|
325
|
+
* effective concurrency, the wave count, the per-lane session budget, and the worst-case total
|
|
326
|
+
* sandbox-minutes — BEFORE any sandbox or provider call. The same plan appears in dry-run,
|
|
327
|
+
* marked $0 (dryRun: true).
|
|
328
|
+
*/
|
|
329
|
+
export function resolveCuaLanePlan(config, opts = {}) {
|
|
330
|
+
return laneSpecsAndPlan(config, opts).plan;
|
|
331
|
+
}
|
|
332
|
+
/** Print the lane plan to stderr BEFORE any sandbox/provider call (public-safe: ids, devices,
|
|
333
|
+
* digests, and budgets only — no prompt text, no secrets). */
|
|
334
|
+
function emitPreflightPlan(plan, labId) {
|
|
335
|
+
const lines = [];
|
|
336
|
+
lines.push(`humanish cua fan-out plan (${labId}): ${plan.laneCount} lane(s), strategy ${plan.strategy}, concurrency ${plan.concurrency}, ${plan.waves} wave(s).`);
|
|
337
|
+
lines.push(` per-lane session budget ${Math.round(plan.perLaneSessionBudgetMs / 1000)}s; worst-case ~${plan.worstCaseSandboxMinutes} sandbox-minutes total${plan.dryRun ? " (dry-run: $0)" : ""}.`);
|
|
338
|
+
for (const lane of plan.lanes) {
|
|
339
|
+
lines.push(` - ${formatLanePlanEntry(lane)}`);
|
|
340
|
+
}
|
|
341
|
+
process.stderr.write(`${lines.join("\n")}\n`);
|
|
342
|
+
}
|
|
343
|
+
function formatLanePlanEntry(lane) {
|
|
344
|
+
const taxonomy = [
|
|
345
|
+
lane.actorType ? `type=${lane.actorType}` : undefined,
|
|
346
|
+
lane.surface ? `surface=${lane.surface}` : undefined,
|
|
347
|
+
lane.caseGroup ? `case=${lane.caseGroup}` : undefined
|
|
348
|
+
].filter((part) => part !== undefined);
|
|
349
|
+
return `${lane.id}: persona=${lane.persona}${taxonomy.length > 0 ? ` ${taxonomy.join(" ")}` : ""} device=${lane.device} ${lane.resolution[0]}x${lane.resolution[1]} prompt#${lane.instructionDigest}${lane.targetDigest ? ` target#${lane.targetDigest}` : ""}`;
|
|
350
|
+
}
|
|
351
|
+
/** ISO timestamp from an injectable clock (tests freeze `now` for deterministic durationMs). */
|
|
352
|
+
function isoNow(now) {
|
|
353
|
+
return new Date(now()).toISOString();
|
|
354
|
+
}
|
|
355
|
+
/** Emit a phase-started event (no ok/durationMs: those belong to the matching completed event). */
|
|
356
|
+
function emitPhaseStarted(onPhase, now, phase, message) {
|
|
357
|
+
onPhase?.({ at: isoNow(now), type: `cua-lab.subject.${phase}.started`, message });
|
|
358
|
+
}
|
|
359
|
+
/** Emit the matching phase-completed event: always carries ok and durationMs (>= 0). */
|
|
360
|
+
function emitPhaseCompleted(onPhase, now, startedAt, phase, ok, message) {
|
|
361
|
+
onPhase?.({
|
|
362
|
+
at: isoNow(now),
|
|
363
|
+
type: `cua-lab.subject.${phase}.completed`,
|
|
364
|
+
ok,
|
|
365
|
+
durationMs: Math.max(0, now() - startedAt),
|
|
366
|
+
message
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/** Default phase-boundary sink (stderr): one line per event, prefixed with the lane id ONLY
|
|
370
|
+
* when laneCount > 1. Single-lane emission is unconditional: total single-lane silence for the
|
|
371
|
+
* whole clone/install/build/ready boot is the bug this event stream exists to close.
|
|
372
|
+
* Overridable via CuaActorLabHooks.onPhase so deterministic tests capture instead of writing to
|
|
373
|
+
* the real stderr. */
|
|
374
|
+
function defaultSubjectPhaseSink(event, ctx) {
|
|
375
|
+
const durationSuffix = event.durationMs === undefined ? "" : ` (${event.durationMs}ms)`;
|
|
376
|
+
const prefix = ctx.laneCount > 1 ? `humanish cua [${ctx.laneId}]` : "humanish cua";
|
|
377
|
+
process.stderr.write(`${prefix}: ${event.message}${durationSuffix}\n`);
|
|
378
|
+
}
|
|
379
|
+
/** Short id-safe suffix for a subject-phase RunEvent: drops the shared prefix/suffix so each
|
|
380
|
+
* phase gets a distinct bundle event id (e.g. "clone", "state-before-build"). */
|
|
381
|
+
function phaseEventIdSuffix(type) {
|
|
382
|
+
return type
|
|
383
|
+
.replace(/^cua-lab\.subject\./, "")
|
|
384
|
+
.replace(/\.(started|completed)$/, "")
|
|
385
|
+
.replace(/\./g, "-");
|
|
386
|
+
}
|
|
387
|
+
/** Build a lane's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
|
|
388
|
+
* the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
|
|
389
|
+
* at N>1). */
|
|
390
|
+
export function makeLaneWriteScreenshot(artifactRoot, spec, screenshots) {
|
|
391
|
+
if (spec.screenshotDir) {
|
|
392
|
+
assertSafeOutputPathSegment(spec.screenshotDir, "Screenshot lane id");
|
|
393
|
+
}
|
|
394
|
+
const dirParts = spec.screenshotDir ? ["screenshots", spec.screenshotDir] : ["screenshots"];
|
|
395
|
+
const relPrefix = spec.screenshotDir ? path.posix.join("screenshots", spec.screenshotDir) : "screenshots";
|
|
396
|
+
return async (name, bytes) => {
|
|
397
|
+
assertSafeOutputPathSegment(name, "Screenshot name");
|
|
398
|
+
const rel = path.posix.join(relPrefix, name);
|
|
399
|
+
assertScreenshotEvidence(rel, bytes);
|
|
400
|
+
await writeContainedOutputFile(artifactRoot, path.join(...dirParts, name), bytes);
|
|
401
|
+
screenshots.push(rel);
|
|
402
|
+
return rel;
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Verify the desktop geometry IN-SANDBOX (the per-lane device claim is checked, never assumed).
|
|
407
|
+
* Returns a fail-closed DEVICE_GEOMETRY message on a PARSEABLE mismatch. When xdpyinfo is
|
|
408
|
+
* unavailable or unparseable (only in fakes — a real E2B desktop always answers), it cannot be
|
|
409
|
+
* verified, so the lane proceeds with the requested geometry (no false failure).
|
|
410
|
+
*/
|
|
411
|
+
async function checkLaneGeometry(desktop, spec, requestTimeoutMs) {
|
|
412
|
+
let out = "";
|
|
413
|
+
try {
|
|
414
|
+
const result = await desktop.commands.run("xdpyinfo 2>/dev/null | grep -i dimensions || true", { requestTimeoutMs });
|
|
415
|
+
out = (result.stdout ?? "").trim();
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
const match = out.match(/(\d+)\s*x\s*(\d+)\s*pixels/i);
|
|
421
|
+
if (!match) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
const width = Number(match[1]);
|
|
425
|
+
const height = Number(match[2]);
|
|
426
|
+
const [expectedWidth, expectedHeight] = spec.resolution;
|
|
427
|
+
if (width === expectedWidth && height === expectedHeight) {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
return `HUMANISH_CUA_LAB_DEVICE_GEOMETRY: lane ${spec.laneId} requested a ${expectedWidth}x${expectedHeight} desktop but xdpyinfo reports ${width}x${height} in-sandbox; the per-lane device geometry is unverified (fail-closed).`;
|
|
431
|
+
}
|
|
432
|
+
/** A blocked lane outcome (pipeline gate / fail-fast skipped it before it ran). */
|
|
433
|
+
function blockedLaneOutcome(spec, reason) {
|
|
434
|
+
return {
|
|
435
|
+
spec,
|
|
436
|
+
killed: false,
|
|
437
|
+
streamUrlPresent: false,
|
|
438
|
+
screenshots: [],
|
|
439
|
+
stateStepRecords: [],
|
|
440
|
+
phaseRecords: [],
|
|
441
|
+
warnings: [],
|
|
442
|
+
skippedReason: reason,
|
|
443
|
+
noEngagement: false,
|
|
444
|
+
selfReportedBlocker: false,
|
|
445
|
+
harnessError: false
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
async function findVisibleBrowserWindowId(desktop, requestTimeoutMs) {
|
|
449
|
+
const result = await desktop.commands.run([
|
|
450
|
+
"set -euo pipefail",
|
|
451
|
+
"export DISPLAY=\"${DISPLAY:-:0}\"",
|
|
452
|
+
"find_chrome_window() {",
|
|
453
|
+
" timeout 2s xdotool search --onlyvisible --class 'google-chrome|Google-chrome|chromium|Chromium|chrome|Chrome' 2>/dev/null | tail -n 1 || true",
|
|
454
|
+
"}",
|
|
455
|
+
"find_firefox_window() {",
|
|
456
|
+
" timeout 2s xdotool search --onlyvisible --class 'firefox|Firefox' 2>/dev/null | tail -n 1 || true",
|
|
457
|
+
"}",
|
|
458
|
+
"find_named_window() {",
|
|
459
|
+
" timeout 2s xdotool search --onlyvisible --name 'Google Chrome|Chromium|Mozilla Firefox|127[.]0[.]0[.]1|localhost|e2b[.]app|e2b[.]dev' 2>/dev/null | tail -n 1 || true",
|
|
460
|
+
"}",
|
|
461
|
+
"window_id=",
|
|
462
|
+
"for _ in $(seq 1 10); do",
|
|
463
|
+
" window_id=\"$(find_chrome_window)\"",
|
|
464
|
+
" if [ -z \"$window_id\" ]; then window_id=\"$(find_firefox_window)\"; fi",
|
|
465
|
+
" if [ -z \"$window_id\" ]; then window_id=\"$(find_named_window)\"; fi",
|
|
466
|
+
" if [ -n \"$window_id\" ]; then break; fi",
|
|
467
|
+
" sleep 0.5",
|
|
468
|
+
"done",
|
|
469
|
+
"if [ -n \"$window_id\" ]; then printf 'WINDOW_ID=%s\\n' \"$window_id\"; fi"
|
|
470
|
+
].join("\n"), {
|
|
471
|
+
requestTimeoutMs,
|
|
472
|
+
timeoutMs: 15_000
|
|
473
|
+
});
|
|
474
|
+
return (result.stdout ?? "").match(/^WINDOW_ID=(\S+)$/m)?.[1];
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Build the xdotool command that makes a browser window fill the desktop.
|
|
478
|
+
* Exported (pure) for contract tests. A window manager can ignore Chrome's
|
|
479
|
+
* --window-size, so xdotool is the robust path: move the window to the origin,
|
|
480
|
+
* then size it to the exact desktop resolution so Observer screenshots carry no
|
|
481
|
+
* dead margin around the browser.
|
|
482
|
+
*/
|
|
483
|
+
export function buildFillDesktopWindowCommand(windowId, width, height) {
|
|
484
|
+
return [
|
|
485
|
+
"set -euo pipefail",
|
|
486
|
+
`win=${shellSingleQuote(windowId)}`,
|
|
487
|
+
`xdotool windowactivate "$win" >/dev/null 2>&1 || true`,
|
|
488
|
+
`xdotool windowmove "$win" 0 0 >/dev/null 2>&1 || true`,
|
|
489
|
+
`xdotool windowsize "$win" ${width} ${height} >/dev/null 2>&1 || true`,
|
|
490
|
+
].join("\n");
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Best-effort: resize the detected browser window to fill the lane's desktop
|
|
494
|
+
* resolution. A failure must never fail the lane (the actor can still run on a
|
|
495
|
+
* smaller window), so all errors are swallowed.
|
|
496
|
+
*/
|
|
497
|
+
async function fillDesktopBrowserWindow(desktop, windowId, resolution, requestTimeoutMs) {
|
|
498
|
+
const [width, height] = resolution;
|
|
499
|
+
await desktop.commands
|
|
500
|
+
.run(buildFillDesktopWindowCommand(windowId, width, height), {
|
|
501
|
+
requestTimeoutMs,
|
|
502
|
+
timeoutMs: 10_000,
|
|
503
|
+
})
|
|
504
|
+
.catch(() => undefined);
|
|
505
|
+
}
|
|
506
|
+
async function openDesktopBrowserTarget(desktop, targetUrl, requestTimeoutMs, browserPreference) {
|
|
507
|
+
const requestedBrowser = browserPreference ?? "default";
|
|
508
|
+
if (isHttpUrl(targetUrl)) {
|
|
509
|
+
const chromiumFlags = CHROMIUM_EVIDENCE_HYGIENE_FLAGS.map(shellSingleQuote).join(" ");
|
|
510
|
+
const browserLaunchCommand = [
|
|
511
|
+
"set -euo pipefail",
|
|
512
|
+
`target_url=${shellSingleQuote(targetUrl)}`,
|
|
513
|
+
`browser_preference=${shellSingleQuote(requestedBrowser)}`,
|
|
514
|
+
"chrome_profile_dir=/tmp/humanish-chrome-profile",
|
|
515
|
+
`chrome_preferences_json=${shellSingleQuote(chromiumEvidenceProfilePreferencesJson())}`,
|
|
516
|
+
"prepare_chrome_profile() {",
|
|
517
|
+
" mkdir -p \"$chrome_profile_dir/Default\"",
|
|
518
|
+
" printf '%s\\n' \"$chrome_preferences_json\" > \"$chrome_profile_dir/Default/Preferences\"",
|
|
519
|
+
"}",
|
|
520
|
+
"launch_browser() {",
|
|
521
|
+
" local label=\"$1\"",
|
|
522
|
+
" local binary=\"$2\"",
|
|
523
|
+
" shift 2",
|
|
524
|
+
" if command -v \"$binary\" >/dev/null 2>&1; then",
|
|
525
|
+
" nohup \"$binary\" \"$@\" \"$target_url\" >/tmp/humanish-browser-open.log 2>&1 &",
|
|
526
|
+
" printf 'HUMANISH_BROWSER_RESOLVED=%s\\n' \"$label\"",
|
|
527
|
+
" return 0",
|
|
528
|
+
" fi",
|
|
529
|
+
" return 1",
|
|
530
|
+
"}",
|
|
531
|
+
`chrome_debug_flags=(--remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 "--user-data-dir=$chrome_profile_dir" ${chromiumFlags})`,
|
|
532
|
+
"prepare_chrome_profile",
|
|
533
|
+
"open_target() {",
|
|
534
|
+
" case \"$browser_preference\" in",
|
|
535
|
+
" chrome)",
|
|
536
|
+
" launch_browser google-chrome google-chrome --new-window \"${chrome_debug_flags[@]}\" && return 0",
|
|
537
|
+
" launch_browser google-chrome-stable google-chrome-stable --new-window \"${chrome_debug_flags[@]}\" && return 0",
|
|
538
|
+
" echo 'requested browser chrome was not found' >&2",
|
|
539
|
+
" return 127",
|
|
540
|
+
" ;;",
|
|
541
|
+
" chromium)",
|
|
542
|
+
" launch_browser chromium chromium --new-window \"${chrome_debug_flags[@]}\" && return 0",
|
|
543
|
+
" launch_browser chromium-browser chromium-browser --new-window \"${chrome_debug_flags[@]}\" && return 0",
|
|
544
|
+
" echo 'requested browser chromium was not found' >&2",
|
|
545
|
+
" return 127",
|
|
546
|
+
" ;;",
|
|
547
|
+
" firefox)",
|
|
548
|
+
" launch_browser firefox firefox --new-window && return 0",
|
|
549
|
+
" echo 'requested browser firefox was not found' >&2",
|
|
550
|
+
" return 127",
|
|
551
|
+
" ;;",
|
|
552
|
+
" default)",
|
|
553
|
+
" launch_browser google-chrome google-chrome --new-window \"${chrome_debug_flags[@]}\" && return 0",
|
|
554
|
+
" launch_browser google-chrome-stable google-chrome-stable --new-window \"${chrome_debug_flags[@]}\" && return 0",
|
|
555
|
+
" launch_browser chromium chromium --new-window \"${chrome_debug_flags[@]}\" && return 0",
|
|
556
|
+
" launch_browser chromium-browser chromium-browser --new-window \"${chrome_debug_flags[@]}\" && return 0",
|
|
557
|
+
" launch_browser firefox firefox --new-window && return 0",
|
|
558
|
+
" launch_browser xdg-open xdg-open && return 0",
|
|
559
|
+
" echo 'no browser opener found' >&2",
|
|
560
|
+
" return 127",
|
|
561
|
+
" ;;",
|
|
562
|
+
" esac",
|
|
563
|
+
"}",
|
|
564
|
+
"open_target"
|
|
565
|
+
].join("\n");
|
|
566
|
+
const result = await runDesktopCommandOrThrow(() => desktop.commands.run(browserLaunchCommand, {
|
|
567
|
+
requestTimeoutMs,
|
|
568
|
+
timeoutMs: 15_000,
|
|
569
|
+
}), ({ exitCode, stderrTail }) => new Error(`browser launch failed${exitCode === undefined ? "" : ` with exit ${exitCode}`}: ${stderrTail}`));
|
|
570
|
+
if (result.exitCode !== undefined && result.exitCode !== 0) {
|
|
571
|
+
throw new Error(`browser launch failed with exit ${result.exitCode}: ${tailOf(result.stderr ?? result.stdout ?? "")}`);
|
|
572
|
+
}
|
|
573
|
+
const resolved = (result.stdout ?? "").match(/^HUMANISH_BROWSER_RESOLVED=(\S+)$/m)?.[1];
|
|
574
|
+
return browserPreference === undefined ? undefined : {
|
|
575
|
+
requested: requestedBrowser,
|
|
576
|
+
...(resolved === undefined ? {} : { resolved })
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
if (browserPreference === undefined || browserPreference === "default") {
|
|
580
|
+
if (desktop.open) {
|
|
581
|
+
await desktop.open(targetUrl);
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
await desktop.launch("google-chrome", targetUrl);
|
|
585
|
+
}
|
|
586
|
+
return browserPreference === undefined ? undefined : { requested: requestedBrowser };
|
|
587
|
+
}
|
|
588
|
+
const launchTarget = requestedBrowser === "chrome" ? "google-chrome"
|
|
589
|
+
: requestedBrowser === "chromium" ? "chromium"
|
|
590
|
+
: requestedBrowser === "firefox" ? "firefox"
|
|
591
|
+
: "google-chrome";
|
|
592
|
+
await desktop.launch(launchTarget, targetUrl);
|
|
593
|
+
return { requested: requestedBrowser, resolved: launchTarget };
|
|
594
|
+
}
|
|
595
|
+
export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs) {
|
|
596
|
+
return async () => {
|
|
597
|
+
const script = [
|
|
598
|
+
"const pages = await fetch('http://127.0.0.1:9222/json').then((r) => r.json()).catch(() => []);",
|
|
599
|
+
"const page = Array.isArray(pages) ? pages.find((entry) => entry && entry.type === 'page' && /^https?:/.test(String(entry.url || ''))) : undefined;",
|
|
600
|
+
"if (!page) { console.log('{}'); process.exit(0); }",
|
|
601
|
+
"let text = '';",
|
|
602
|
+
"let url = String(page.url || '');",
|
|
603
|
+
"let title = String(page.title || '');",
|
|
604
|
+
"if (typeof WebSocket === 'function' && page.webSocketDebuggerUrl) {",
|
|
605
|
+
" const ws = new WebSocket(page.webSocketDebuggerUrl);",
|
|
606
|
+
" const result = await new Promise((resolve) => {",
|
|
607
|
+
" const timer = setTimeout(() => resolve(undefined), 1500);",
|
|
608
|
+
" ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { returnByValue: true, expression: '({ url: location.href, title: document.title, text: (document.body && document.body.innerText || \"\").slice(0, 20000) })' } }));",
|
|
609
|
+
" ws.onmessage = (event) => {",
|
|
610
|
+
" try {",
|
|
611
|
+
" const payload = JSON.parse(String(event.data));",
|
|
612
|
+
" if (payload.id !== 1) return;",
|
|
613
|
+
" clearTimeout(timer);",
|
|
614
|
+
" resolve(payload.result && payload.result.result && payload.result.result.value);",
|
|
615
|
+
" } catch { clearTimeout(timer); resolve(undefined); }",
|
|
616
|
+
" };",
|
|
617
|
+
" ws.onerror = () => { clearTimeout(timer); resolve(undefined); };",
|
|
618
|
+
" }).finally(() => { try { ws.close(); } catch {} });",
|
|
619
|
+
" if (result && typeof result === 'object') {",
|
|
620
|
+
" url = typeof result.url === 'string' ? result.url : url;",
|
|
621
|
+
" title = typeof result.title === 'string' ? result.title : title;",
|
|
622
|
+
" text = typeof result.text === 'string' ? result.text : '';",
|
|
623
|
+
" }",
|
|
624
|
+
"}",
|
|
625
|
+
"console.log(JSON.stringify({ url, title, text }));"
|
|
626
|
+
].join("\n");
|
|
627
|
+
const result = await desktop.commands.run(`node --input-type=module -e ${shellSingleQuote(script)}`, {
|
|
628
|
+
requestTimeoutMs,
|
|
629
|
+
timeoutMs: 5_000
|
|
630
|
+
});
|
|
631
|
+
if (result.exitCode !== undefined && result.exitCode !== 0) {
|
|
632
|
+
return {};
|
|
633
|
+
}
|
|
634
|
+
try {
|
|
635
|
+
const parsed = JSON.parse((result.stdout ?? "{}").trim() || "{}");
|
|
636
|
+
if (!parsed || typeof parsed !== "object") {
|
|
637
|
+
return {};
|
|
638
|
+
}
|
|
639
|
+
const record = parsed;
|
|
640
|
+
return {
|
|
641
|
+
...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
|
|
642
|
+
...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
|
|
643
|
+
...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {})
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
return {};
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
function shellSingleQuote(value) {
|
|
652
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
653
|
+
}
|
|
654
|
+
async function startDesktopStream(desktop, browserWindowId) {
|
|
655
|
+
if (!browserWindowId) {
|
|
656
|
+
await desktop.stream.start({ requireAuth: true });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
try {
|
|
660
|
+
await desktop.stream.start({ requireAuth: true, windowId: browserWindowId });
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
await desktop.stream.start({ requireAuth: true });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function completionReasonContradictsGoal(reason) {
|
|
667
|
+
const text = stripNegatedNonBlockerPhrases(reason.toLowerCase());
|
|
668
|
+
return /\b(can'?t|cannot|could not|unable|blocked|blocker|failed|invalid|not set)\b/.test(text)
|
|
669
|
+
|| /\b(shows|showing|hit|encountered|returned|got)\b.{0,80}\berror\b/.test(text)
|
|
670
|
+
|| /\berror[:.]/.test(text)
|
|
671
|
+
|| /what would you like me to do|please tell me|need (the )?(task|credentials|instructions)/.test(text);
|
|
672
|
+
}
|
|
673
|
+
function stripNegatedNonBlockerPhrases(text) {
|
|
674
|
+
return text
|
|
675
|
+
.replace(/\bno\s+(?:real\s+|remaining\s+|actual\s+)?(?:blocker|blockers|blocking issue|blocking issues|error|errors|failure|failures)\s+(?:was\s+|were\s+)?(?:encountered|observed|found|hit|seen|reported|detected)\b/g, "")
|
|
676
|
+
.replace(/\bwithout\s+(?:a\s+|any\s+)?(?:real\s+|remaining\s+|actual\s+)?(?:blocker|blockers|blocking issue|blocking issues|error|errors|failure|failures)\b/g, "")
|
|
677
|
+
.replace(/\bnot\s+(?:blocked|a blocker|an error|failed)\b/g, "");
|
|
678
|
+
}
|
|
679
|
+
function traceHasStopWhenMatch(session) {
|
|
680
|
+
return session.trace.items.some((item) => item.kind === "notice"
|
|
681
|
+
&& item.status === "matched"
|
|
682
|
+
&& item.title.startsWith("stopWhen matched"));
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Run ONE E2B desktop lane end-to-end: create the sandbox (per-lane metadata + the lane's device
|
|
686
|
+
* resolution), prepareDesktop, verify geometry, (clone+serve+seed the subject per lane), open the
|
|
687
|
+
* browser, run the session, and ALWAYS tear down THIS lane's sandbox BY ID in a finally. Never
|
|
688
|
+
* enumerates sandboxes. Extracted from the former single-lane block; at N=1 it writes the exact
|
|
689
|
+
* same artifacts (actor.json, screenshots/<name>) the bundle has always referenced.
|
|
690
|
+
*/
|
|
691
|
+
export async function runCuaLane(spec, deps) {
|
|
692
|
+
const { config, appUrl, cloneRoute, localTreeRoute, serve, subjectRepo, subjectEnvNames } = deps;
|
|
693
|
+
const targetUrl = spec.targetUrl ?? appUrl;
|
|
694
|
+
const env = deps.env;
|
|
695
|
+
const warnings = [];
|
|
696
|
+
const screenshots = [];
|
|
697
|
+
const writeScreenshot = makeLaneWriteScreenshot(deps.artifactRoot, spec, screenshots);
|
|
698
|
+
const stateStepRecords = [];
|
|
699
|
+
// Completed-only trail (durationMs/ok are set on completed events, never on started ones):
|
|
700
|
+
// this is what survives into bundle.events. The default/injected sink below sees EVERY event,
|
|
701
|
+
// started and completed alike, so an operator watching stderr sees both halves of each phase.
|
|
702
|
+
const phaseRecords = [];
|
|
703
|
+
const onSubjectPhase = (event) => {
|
|
704
|
+
if (event.ok !== undefined) {
|
|
705
|
+
phaseRecords.push(event);
|
|
706
|
+
}
|
|
707
|
+
(deps.hooks.onPhase ?? defaultSubjectPhaseSink)(event, { laneId: spec.laneId, laneCount: deps.laneCount });
|
|
708
|
+
};
|
|
709
|
+
let session;
|
|
710
|
+
let sessionError;
|
|
711
|
+
let failureCode;
|
|
712
|
+
let sandboxId;
|
|
713
|
+
let killed = false;
|
|
714
|
+
let streamUrl;
|
|
715
|
+
let subjectCommit;
|
|
716
|
+
let desktopBrowser;
|
|
717
|
+
let provisioned = false;
|
|
718
|
+
let signaled = false;
|
|
719
|
+
const signal = (ok) => {
|
|
720
|
+
if (!signaled && deps.signalProvisioned) {
|
|
721
|
+
signaled = true;
|
|
722
|
+
deps.signalProvisioned(ok);
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
let desktopModule;
|
|
726
|
+
let desktop;
|
|
727
|
+
try {
|
|
728
|
+
desktopModule = await (deps.hooks.loadDesktopModule ?? loadE2BDesktopModule)();
|
|
729
|
+
// Optional custom desktop template (image): present → Sandbox.create(template, opts); absent →
|
|
730
|
+
// the byte-stable Sandbox.create(opts) default (stock `desktop` template).
|
|
731
|
+
desktop = await createDesktopSandbox(desktopModule, {
|
|
732
|
+
apiKey: deps.e2bApiKey,
|
|
733
|
+
requestTimeoutMs: deps.requestTimeoutMs,
|
|
734
|
+
timeoutMs: deps.perLaneSandboxMs,
|
|
735
|
+
metadata: {
|
|
736
|
+
...CUA_ACTOR_LAB_PROVIDER_METADATA,
|
|
737
|
+
labId: config.id,
|
|
738
|
+
simId: spec.simId,
|
|
739
|
+
laneId: spec.laneId,
|
|
740
|
+
laneIndex: String(spec.laneIndex),
|
|
741
|
+
laneCount: String(deps.laneCount)
|
|
742
|
+
},
|
|
743
|
+
// Env placement per the doctrine: the ACTOR's key never enters the sandbox (the model drives
|
|
744
|
+
// from outside). The SUBJECT's declared env NAMES are provisioned here on the clone route.
|
|
745
|
+
...(subjectEnvNames.length > 0
|
|
746
|
+
? { envs: Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])) }
|
|
747
|
+
: {}),
|
|
748
|
+
resolution: spec.resolution,
|
|
749
|
+
dpi: 96,
|
|
750
|
+
lifecycle: { onTimeout: "kill" }
|
|
751
|
+
}, config.execution?.desktop?.template);
|
|
752
|
+
sandboxId = desktop.sandboxId;
|
|
753
|
+
if (deps.hooks.prepareDesktop) {
|
|
754
|
+
await deps.hooks.prepareDesktop(desktop, { laneId: spec.laneId, laneIndex: spec.laneIndex, laneCount: deps.laneCount });
|
|
755
|
+
}
|
|
756
|
+
// Per-lane geometry assertion (fail-closed) — the device claim is verified in-sandbox.
|
|
757
|
+
const geometryError = await checkLaneGeometry(desktop, spec, deps.requestTimeoutMs);
|
|
758
|
+
if (geometryError) {
|
|
759
|
+
sessionError = geometryError;
|
|
760
|
+
failureCode = "HUMANISH_CUA_LAB_DEVICE_GEOMETRY";
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
if (cloneRoute && serve && subjectRepo) {
|
|
764
|
+
subjectCommit = await provisionCloneSubject(desktop, {
|
|
765
|
+
repo: subjectRepo,
|
|
766
|
+
depth: config.subject.clone?.depth ?? 1,
|
|
767
|
+
serve,
|
|
768
|
+
...(config.subject.state === undefined ? {} : { state: config.subject.state }),
|
|
769
|
+
hasGithubToken: deps.hasGithubToken,
|
|
770
|
+
requestTimeoutMs: deps.requestTimeoutMs,
|
|
771
|
+
scrub: deps.scrubKnownValues,
|
|
772
|
+
onCommit: (commit) => {
|
|
773
|
+
subjectCommit = commit;
|
|
774
|
+
},
|
|
775
|
+
onStateStep: (record) => {
|
|
776
|
+
stateStepRecords.push(record);
|
|
777
|
+
},
|
|
778
|
+
onPhase: onSubjectPhase,
|
|
779
|
+
...(deps.hooks.detachedTimers ?? {})
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
else if (localTreeRoute && serve && deps.localTreeArchiveBuffer) {
|
|
783
|
+
await provisionLocalTreeSubject(desktop, {
|
|
784
|
+
archiveBuffer: deps.localTreeArchiveBuffer,
|
|
785
|
+
serve,
|
|
786
|
+
...(config.subject.state === undefined ? {} : { state: config.subject.state }),
|
|
787
|
+
requestTimeoutMs: deps.requestTimeoutMs,
|
|
788
|
+
scrub: deps.scrubKnownValues,
|
|
789
|
+
onStateStep: (record) => {
|
|
790
|
+
stateStepRecords.push(record);
|
|
791
|
+
},
|
|
792
|
+
onPhase: onSubjectPhase,
|
|
793
|
+
...(deps.hooks.detachedTimers ?? {})
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
desktopBrowser = await openDesktopBrowserTarget(desktop, targetUrl, deps.requestTimeoutMs, config.execution?.desktop?.browser);
|
|
797
|
+
await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
|
|
798
|
+
// World is ready: release the pipeline gate so the remaining lanes may start.
|
|
799
|
+
provisioned = true;
|
|
800
|
+
signal(true);
|
|
801
|
+
try {
|
|
802
|
+
const browserWindowId = await findVisibleBrowserWindowId(desktop, deps.requestTimeoutMs)
|
|
803
|
+
.catch((error) => {
|
|
804
|
+
warnings.push(`Browser window lookup failed before live stream start (falling back to desktop stream): ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
|
|
805
|
+
return undefined;
|
|
806
|
+
});
|
|
807
|
+
if (browserWindowId !== undefined) {
|
|
808
|
+
await fillDesktopBrowserWindow(desktop, browserWindowId, spec.resolution, deps.requestTimeoutMs);
|
|
809
|
+
}
|
|
810
|
+
await startDesktopStream(desktop, browserWindowId);
|
|
811
|
+
const candidateStreamUrl = desktop.stream.getUrl({
|
|
812
|
+
authKey: desktop.stream.getAuthKey(),
|
|
813
|
+
autoConnect: true,
|
|
814
|
+
viewOnly: true,
|
|
815
|
+
resize: "scale"
|
|
816
|
+
});
|
|
817
|
+
if (typeof candidateStreamUrl === "string" && candidateStreamUrl.trim().length > 0) {
|
|
818
|
+
streamUrl = candidateStreamUrl;
|
|
819
|
+
await deps.hooks.onRuntimeStreamReady?.({
|
|
820
|
+
laneId: spec.laneId,
|
|
821
|
+
sandboxId: desktop.sandboxId,
|
|
822
|
+
simId: spec.simId,
|
|
823
|
+
streamId: spec.streamId,
|
|
824
|
+
url: streamUrl
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
warnings.push("Live desktop stream started but did not return a usable watch URL; Observer will fall back to screenshots.");
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
warnings.push(`Live desktop stream unavailable (run continues; evidence still captured): ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
|
|
833
|
+
}
|
|
834
|
+
const sessionOptions = {
|
|
835
|
+
instructions: spec.instructions,
|
|
836
|
+
persona: spec.persona,
|
|
837
|
+
timeoutMs: deps.timeoutMs,
|
|
838
|
+
openai: {
|
|
839
|
+
apiKey: deps.openaiApiKey,
|
|
840
|
+
...(config.actors[0]?.model ? { model: config.actors[0].model } : {})
|
|
841
|
+
},
|
|
842
|
+
desktop: desktop,
|
|
843
|
+
executorOptions: {
|
|
844
|
+
observeBrowserState: makeChromeBrowserStateObserver(desktop, deps.requestTimeoutMs)
|
|
845
|
+
},
|
|
846
|
+
redactScreenshots: deps.redactScreenshots,
|
|
847
|
+
scrubText: deps.scrubKnownValues,
|
|
848
|
+
writeScreenshot,
|
|
849
|
+
...(spec.stopWhen === undefined ? {} : { stopWhen: spec.stopWhen })
|
|
850
|
+
};
|
|
851
|
+
session = await deps.runSession(sessionOptions);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
catch (error) {
|
|
855
|
+
sessionError = redactText(deps.scrubKnownValues(toErrorMessage(error)));
|
|
856
|
+
}
|
|
857
|
+
finally {
|
|
858
|
+
if (!provisioned) {
|
|
859
|
+
signal(false);
|
|
860
|
+
}
|
|
861
|
+
if (desktop && desktopModule) {
|
|
862
|
+
const failed = sessionError !== undefined || session === undefined;
|
|
863
|
+
// Each route's own keep flag gates its own lane only: a clone.keep can never leak into
|
|
864
|
+
// a local-tree lane's teardown decision, and vice versa.
|
|
865
|
+
const keepReason = cloneRoute && config.subject.clone?.keep === true
|
|
866
|
+
? "subject.clone.keep"
|
|
867
|
+
: localTreeRoute && config.subject.localTree?.keep === true
|
|
868
|
+
? "subject.localTree.keep"
|
|
869
|
+
: undefined;
|
|
870
|
+
const keepForDebug = keepReason !== undefined && failed;
|
|
871
|
+
if (keepForDebug) {
|
|
872
|
+
warnings.push(`Sandbox ${desktop.sandboxId} kept for debugging (${keepReason} on failure); reclaim it via E2B or it will be killed on its server-side timeout.`);
|
|
873
|
+
}
|
|
874
|
+
else if (typeof desktopModule.Sandbox.kill === "function") {
|
|
875
|
+
try {
|
|
876
|
+
await desktopModule.Sandbox.kill(desktop.sandboxId, { requestTimeoutMs: 60_000 });
|
|
877
|
+
killed = true;
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
warnings.push(`Sandbox teardown failed (server-side kill-on-timeout will reclaim it): ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
warnings.push("Installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the sandbox.");
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (session) {
|
|
889
|
+
await writeContainedOutputFile(deps.artifactRoot, spec.traceArtifactPath, `${JSON.stringify(session.trace, null, 2)}\n`, "utf8");
|
|
890
|
+
if (session.trace.redaction.screenshots === "raw") {
|
|
891
|
+
warnings.push("Screenshots are full-fidelity (raw) for local use — the bundle stays in gitignored .humanish and nothing scans these pixels; review them before sharing anywhere. Set policies.redactScreenshots: true to blur a share-as-is bundle.");
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const noEngagement = session !== undefined
|
|
895
|
+
&& session.completionReason === "goal_satisfied"
|
|
896
|
+
&& (session.trace.counts.actions ?? 0) === 0
|
|
897
|
+
&& (session.trace.counts.messages ?? 0) === 0
|
|
898
|
+
&& !traceHasStopWhenMatch(session);
|
|
899
|
+
if (noEngagement) {
|
|
900
|
+
warnings.push("Actor returned goal_satisfied with ZERO actions and ZERO messages — it likely saw a blank or still-loading screen and stopped without engaging. NOT counted as a pass. Check the screenshot; raise execution.timeoutMs or confirm the subject painted before the first turn.");
|
|
901
|
+
}
|
|
902
|
+
const blockerReason = session?.completionReason === "goal_satisfied" && completionReasonContradictsGoal(session.reason)
|
|
903
|
+
? session.reason
|
|
904
|
+
: undefined;
|
|
905
|
+
const selfReportedBlocker = blockerReason !== undefined;
|
|
906
|
+
if (selfReportedBlocker) {
|
|
907
|
+
warnings.push(`Actor returned goal_satisfied while its final message describes a blocker or asks for missing instructions — NOT counted as a pass: ${redactText(deps.scrubKnownValues(blockerReason))}`);
|
|
908
|
+
}
|
|
909
|
+
const harnessError = sessionError !== undefined || session?.completionReason === "harness_error";
|
|
910
|
+
return {
|
|
911
|
+
spec,
|
|
912
|
+
...(session ? { session } : {}),
|
|
913
|
+
...(sessionError === undefined ? {} : { sessionError }),
|
|
914
|
+
...(sandboxId === undefined ? {} : { sandboxId }),
|
|
915
|
+
killed,
|
|
916
|
+
streamUrlPresent: streamUrl !== undefined,
|
|
917
|
+
screenshots,
|
|
918
|
+
...(subjectCommit === undefined ? {} : { subjectCommit }),
|
|
919
|
+
...(desktopBrowser === undefined ? {} : { desktopBrowser }),
|
|
920
|
+
stateStepRecords,
|
|
921
|
+
phaseRecords,
|
|
922
|
+
warnings,
|
|
923
|
+
noEngagement,
|
|
924
|
+
selfReportedBlocker,
|
|
925
|
+
harnessError,
|
|
926
|
+
...(failureCode === undefined ? {} : { failureCode })
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
/** Run the single IN-PROCESS lane (a custom executor + provider; NO E2B). Always one lane. */
|
|
930
|
+
async function runInProcessLane(spec, deps) {
|
|
931
|
+
const warnings = [];
|
|
932
|
+
const screenshots = [];
|
|
933
|
+
const writeScreenshot = makeLaneWriteScreenshot(deps.artifactRoot, spec, screenshots);
|
|
934
|
+
let session;
|
|
935
|
+
let sessionError;
|
|
936
|
+
try {
|
|
937
|
+
const executor = await deps.hooks.buildExecutor({ config: deps.config, actor: deps.descriptor, appUrl: deps.appUrl });
|
|
938
|
+
const provider = await deps.hooks.buildProvider({ config: deps.config, actor: deps.descriptor });
|
|
939
|
+
const sessionOptions = {
|
|
940
|
+
instructions: spec.instructions,
|
|
941
|
+
persona: spec.persona,
|
|
942
|
+
timeoutMs: deps.timeoutMs,
|
|
943
|
+
provider,
|
|
944
|
+
executor,
|
|
945
|
+
redactScreenshots: deps.redactScreenshots,
|
|
946
|
+
scrubText: deps.scrubKnownValues,
|
|
947
|
+
writeScreenshot,
|
|
948
|
+
...(spec.stopWhen === undefined ? {} : { stopWhen: spec.stopWhen })
|
|
949
|
+
};
|
|
950
|
+
session = await deps.runSession(sessionOptions);
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
sessionError = redactText(deps.scrubKnownValues(toErrorMessage(error)));
|
|
954
|
+
}
|
|
955
|
+
if (session) {
|
|
956
|
+
await writeContainedOutputFile(deps.artifactRoot, spec.traceArtifactPath, `${JSON.stringify(session.trace, null, 2)}\n`, "utf8");
|
|
957
|
+
if (session.trace.redaction.screenshots === "raw") {
|
|
958
|
+
warnings.push("Screenshots are full-fidelity (raw) for local use — the bundle stays in gitignored .humanish and nothing scans these pixels; review them before sharing anywhere. Set policies.redactScreenshots: true to blur a share-as-is bundle.");
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
const noEngagement = session !== undefined
|
|
962
|
+
&& session.completionReason === "goal_satisfied"
|
|
963
|
+
&& (session.trace.counts.actions ?? 0) === 0
|
|
964
|
+
&& (session.trace.counts.messages ?? 0) === 0
|
|
965
|
+
&& !traceHasStopWhenMatch(session);
|
|
966
|
+
if (noEngagement) {
|
|
967
|
+
warnings.push("Actor returned goal_satisfied with ZERO actions and ZERO messages — it likely saw a blank or still-loading screen and stopped without engaging. NOT counted as a pass. Check the screenshot; raise execution.timeoutMs or confirm the subject painted before the first turn.");
|
|
968
|
+
}
|
|
969
|
+
const blockerReason = session?.completionReason === "goal_satisfied" && completionReasonContradictsGoal(session.reason)
|
|
970
|
+
? session.reason
|
|
971
|
+
: undefined;
|
|
972
|
+
const selfReportedBlocker = blockerReason !== undefined;
|
|
973
|
+
if (selfReportedBlocker) {
|
|
974
|
+
warnings.push(`Actor returned goal_satisfied while its final message describes a blocker or asks for missing instructions — NOT counted as a pass: ${blockerReason}`);
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
spec,
|
|
978
|
+
...(session ? { session } : {}),
|
|
979
|
+
...(sessionError === undefined ? {} : { sessionError }),
|
|
980
|
+
killed: false,
|
|
981
|
+
streamUrlPresent: false,
|
|
982
|
+
screenshots,
|
|
983
|
+
stateStepRecords: [],
|
|
984
|
+
phaseRecords: [],
|
|
985
|
+
warnings,
|
|
986
|
+
noEngagement,
|
|
987
|
+
selfReportedBlocker,
|
|
988
|
+
harnessError: sessionError !== undefined || session?.completionReason === "harness_error",
|
|
989
|
+
entryKind: "local-app"
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Run N>1 E2B lanes with bounded concurrency, a pipeline gate (lane 1 provisions before the rest
|
|
994
|
+
* start), and session fail-fast on HARNESS errors only (queued lanes become `blocked` with a
|
|
995
|
+
* pinned reason + a fail-fast event; mission verdicts never trip it). Each lane tears down ITS
|
|
996
|
+
* OWN sandbox by id; nothing here ever enumerates.
|
|
997
|
+
*/
|
|
998
|
+
async function runCuaLanes(laneSpecs, deps, concurrency) {
|
|
999
|
+
const failFast = { tripped: false, reason: "" };
|
|
1000
|
+
let resolveGate;
|
|
1001
|
+
let rejectGate;
|
|
1002
|
+
const gate = new Promise((resolve, reject) => {
|
|
1003
|
+
resolveGate = resolve;
|
|
1004
|
+
rejectGate = () => reject(new Error("gate"));
|
|
1005
|
+
});
|
|
1006
|
+
// The gate is rejected on lane-0 provisioning failure; swallow the unhandled rejection if no
|
|
1007
|
+
// later lane ever awaits it (concurrency could let lane 0 finish alone).
|
|
1008
|
+
gate.catch(() => undefined);
|
|
1009
|
+
const outcomes = await mapWithConcurrency(laneSpecs, concurrency, async (spec, index) => {
|
|
1010
|
+
if (index > 0) {
|
|
1011
|
+
try {
|
|
1012
|
+
await gate;
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
return blockedLaneOutcome(spec, `skipped: lane ${laneSpecs[0]?.laneId ?? "lane-01"} failed to provision its world (pipeline gate)`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (failFast.tripped) {
|
|
1019
|
+
return blockedLaneOutcome(spec, `skipped: ${failFast.reason}`);
|
|
1020
|
+
}
|
|
1021
|
+
const outcome = await runCuaLane(spec, {
|
|
1022
|
+
...deps,
|
|
1023
|
+
...(index === 0
|
|
1024
|
+
? {
|
|
1025
|
+
signalProvisioned: (ok) => {
|
|
1026
|
+
if (ok) {
|
|
1027
|
+
resolveGate?.();
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
rejectGate?.();
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
: {})
|
|
1035
|
+
});
|
|
1036
|
+
if (outcome.harnessError && !failFast.tripped) {
|
|
1037
|
+
failFast.tripped = true;
|
|
1038
|
+
failFast.reason = `a prior lane (${outcome.spec.laneId}) ended in a harness error (fail-fast)`;
|
|
1039
|
+
}
|
|
1040
|
+
return outcome;
|
|
1041
|
+
});
|
|
1042
|
+
return { outcomes, ...(failFast.tripped ? { failFastReason: failFast.reason } : {}) };
|
|
1043
|
+
}
|
|
1044
|
+
/** Project one lane outcome (or a dry-run contract spec) into the public CuaLaneResult. */
|
|
1045
|
+
function toLaneResult(spec, outcome, subject, dryRun) {
|
|
1046
|
+
const base = {
|
|
1047
|
+
id: spec.laneId,
|
|
1048
|
+
...(spec.actorType === undefined ? {} : { actorType: spec.actorType }),
|
|
1049
|
+
...(spec.surface === undefined ? {} : { surface: spec.surface }),
|
|
1050
|
+
...(spec.caseGroup === undefined ? {} : { caseGroup: spec.caseGroup }),
|
|
1051
|
+
index: spec.laneIndex + 1,
|
|
1052
|
+
persona: spec.persona.id,
|
|
1053
|
+
device: spec.deviceName,
|
|
1054
|
+
resolution: spec.resolution,
|
|
1055
|
+
subject
|
|
1056
|
+
};
|
|
1057
|
+
if (!outcome || dryRun) {
|
|
1058
|
+
return { ...base, status: "contract_proof_only", ok: dryRun };
|
|
1059
|
+
}
|
|
1060
|
+
if (outcome.skippedReason !== undefined) {
|
|
1061
|
+
return {
|
|
1062
|
+
...base,
|
|
1063
|
+
status: "blocked",
|
|
1064
|
+
ok: false,
|
|
1065
|
+
skippedReason: outcome.skippedReason,
|
|
1066
|
+
error: { code: "HUMANISH_CUA_LAB_FAILED", message: outcome.skippedReason }
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
const session = outcome.session;
|
|
1070
|
+
const laneOk = laneOutcomeOk(outcome, dryRun);
|
|
1071
|
+
const status = session ? session.status : "failed";
|
|
1072
|
+
return {
|
|
1073
|
+
...base,
|
|
1074
|
+
status,
|
|
1075
|
+
ok: laneOk,
|
|
1076
|
+
...(session
|
|
1077
|
+
? {
|
|
1078
|
+
session: {
|
|
1079
|
+
status: session.status,
|
|
1080
|
+
completionReason: session.completionReason,
|
|
1081
|
+
reason: session.reason,
|
|
1082
|
+
screenshots: outcome.screenshots.length
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
: {}),
|
|
1086
|
+
...(outcome.sandboxId === undefined
|
|
1087
|
+
? {}
|
|
1088
|
+
: { sandbox: { sandboxId: outcome.sandboxId, killed: outcome.killed, streamUrlPresent: outcome.streamUrlPresent } }),
|
|
1089
|
+
...(laneOk
|
|
1090
|
+
? {}
|
|
1091
|
+
: {
|
|
1092
|
+
error: {
|
|
1093
|
+
code: outcome.failureCode ?? "HUMANISH_CUA_LAB_FAILED",
|
|
1094
|
+
message: outcome.sessionError
|
|
1095
|
+
?? (outcome.noEngagement
|
|
1096
|
+
? "Actor took no actions and produced no message (likely a blank/still-loading screen); not a credible goal_satisfied."
|
|
1097
|
+
: outcome.selfReportedBlocker
|
|
1098
|
+
? "Actor reported goal_satisfied while its final message described a blocker or asked for missing instructions; not a credible pass."
|
|
1099
|
+
: session?.completionReason === "harness_error"
|
|
1100
|
+
? `Computer-use session ended with a harness error: ${session.reason}`
|
|
1101
|
+
: session?.status !== "passed"
|
|
1102
|
+
? `Computer-use session ended with ${session?.status ?? "unknown"}: ${session?.reason ?? "no terminal reason"}`
|
|
1103
|
+
: "Computer-use lab did not produce a terminal session.")
|
|
1104
|
+
}
|
|
1105
|
+
})
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
function laneOutcomeOk(outcome, dryRun) {
|
|
1109
|
+
if (dryRun)
|
|
1110
|
+
return true;
|
|
1111
|
+
if (!outcome || outcome.skippedReason !== undefined)
|
|
1112
|
+
return false;
|
|
1113
|
+
return outcome.session !== undefined
|
|
1114
|
+
&& outcome.session.status === "passed"
|
|
1115
|
+
&& outcome.session.completionReason !== "harness_error"
|
|
1116
|
+
&& outcome.sessionError === undefined
|
|
1117
|
+
&& !outcome.noEngagement
|
|
1118
|
+
&& !outcome.selfReportedBlocker;
|
|
1119
|
+
}
|
|
1120
|
+
function fanoutReviewVerdict(args) {
|
|
1121
|
+
if (args.dryRun)
|
|
1122
|
+
return "contract_proof_only";
|
|
1123
|
+
const outcomes = args.outcomes ?? [];
|
|
1124
|
+
if (outcomes.length !== args.expectedLaneCount)
|
|
1125
|
+
return "fail";
|
|
1126
|
+
if (outcomes.some((outcome) => !laneOutcomeOk(outcome, false))) {
|
|
1127
|
+
return outcomes.some((outcome) => outcome.session?.status === "timed_out")
|
|
1128
|
+
? "timed_out"
|
|
1129
|
+
: "fail";
|
|
1130
|
+
}
|
|
1131
|
+
return "pass";
|
|
1132
|
+
}
|
|
1133
|
+
/** Build the per-lane subject projection (invariant 5). Local-tree lanes all share ONE
|
|
1134
|
+
* host-packed archive, so every lane's projection carries the identical archiveSha256/
|
|
1135
|
+
* commit/dirty (no per-lane divergence is possible, unlike the clone route's per-lane
|
|
1136
|
+
* in-sandbox commit). */
|
|
1137
|
+
function laneSubjectProjection(args) {
|
|
1138
|
+
if (args.cloneRoute && args.publicRepo) {
|
|
1139
|
+
return {
|
|
1140
|
+
source: "clone",
|
|
1141
|
+
repo: args.publicRepo,
|
|
1142
|
+
...(args.subjectCommit === undefined ? {} : { commit: args.subjectCommit }),
|
|
1143
|
+
envNames: args.subjectEnvNames,
|
|
1144
|
+
state: args.subjectState
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
if (args.localTreeRoute) {
|
|
1148
|
+
const archive = args.localTreeArchive;
|
|
1149
|
+
return {
|
|
1150
|
+
source: "local-tree",
|
|
1151
|
+
...(archive === undefined ? {} : { archiveSha256: archive.archiveSha256 }),
|
|
1152
|
+
...(archive?.git === undefined ? {} : { commit: archive.git.commit, dirty: archive.git.dirty }),
|
|
1153
|
+
envNames: args.subjectEnvNames,
|
|
1154
|
+
state: args.subjectState
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
return { source: "app-url", state: args.subjectState };
|
|
1158
|
+
}
|
|
1159
|
+
/** Narrow a resolved CuaSubjectProjection into the shape buildCuaBundle/buildSingleLaneBundle's
|
|
1160
|
+
* subjectProvenance param wants (provisioned-route sources only; app-url stays undeclared, the
|
|
1161
|
+
* default branch buildCuaBundle already handles). */
|
|
1162
|
+
function subjectProvenanceArg(subject, publicRepo, subjectEnvNames) {
|
|
1163
|
+
if (subject.source === "clone" && publicRepo) {
|
|
1164
|
+
return {
|
|
1165
|
+
source: "clone",
|
|
1166
|
+
repo: publicRepo,
|
|
1167
|
+
...(subject.commit === undefined ? {} : { commit: subject.commit }),
|
|
1168
|
+
envNames: subjectEnvNames,
|
|
1169
|
+
state: subject.state
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
if (subject.source === "local-tree") {
|
|
1173
|
+
return {
|
|
1174
|
+
source: "local-tree",
|
|
1175
|
+
...(subject.archiveSha256 === undefined ? {} : { archiveSha256: subject.archiveSha256 }),
|
|
1176
|
+
...(subject.commit === undefined ? {} : { commit: subject.commit }),
|
|
1177
|
+
...(subject.dirty === undefined ? {} : { dirty: subject.dirty }),
|
|
1178
|
+
envNames: subjectEnvNames,
|
|
1179
|
+
state: subject.state
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
return undefined;
|
|
1183
|
+
}
|
|
1184
|
+
export async function runCuaActorLab(options) {
|
|
1185
|
+
const { config, dryRun } = options;
|
|
1186
|
+
// Capture the physical project before reading or invoking any caller hook. A supported
|
|
1187
|
+
// symlink cwd remains valid, but retargeting that alias from a hook cannot redirect source
|
|
1188
|
+
// reads, local-tree packing, managed run storage, or Observer output into another project.
|
|
1189
|
+
const physicalCwd = await realpath(path.resolve(options.cwd));
|
|
1190
|
+
const projectRoot = await prepareSelectedOutputDirectory(path.dirname(physicalCwd), physicalCwd);
|
|
1191
|
+
const cwd = projectRoot.physicalPath;
|
|
1192
|
+
const hooks = options.hooks ?? {};
|
|
1193
|
+
let liveObserver;
|
|
1194
|
+
const runtimeStreamUrls = [];
|
|
1195
|
+
const liveHooks = {
|
|
1196
|
+
...hooks,
|
|
1197
|
+
onRuntimeStreamReady: async (stream) => {
|
|
1198
|
+
await hooks.onRuntimeStreamReady?.(stream);
|
|
1199
|
+
runtimeStreamUrls.push({ streamId: stream.streamId, url: stream.url });
|
|
1200
|
+
if (liveObserver) {
|
|
1201
|
+
attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
const env = hooks.env ?? process.env;
|
|
1206
|
+
const render = hooks.renderObserverFn ?? renderObserver;
|
|
1207
|
+
const cloneRoute = config.subject.source === "clone";
|
|
1208
|
+
const localTreeRoute = config.subject.source === "local-tree";
|
|
1209
|
+
// Both routes provision the subject in-sandbox (clone via git, local-tree via pack+upload)
|
|
1210
|
+
// and then share the identical install/build/state/start/probe pipeline, so every seam that
|
|
1211
|
+
// gates on "does this route provision a subject" is keyed on this union, not on cloneRoute
|
|
1212
|
+
// alone.
|
|
1213
|
+
const provisionedRoute = cloneRoute || localTreeRoute;
|
|
1214
|
+
const serve = config.subject.serve;
|
|
1215
|
+
const appUrl = (provisionedRoute ? serve?.url : config.subject.appUrl) ?? "";
|
|
1216
|
+
const subjectRepo = cloneRoute ? config.subject.repos?.[0] ?? "" : undefined;
|
|
1217
|
+
const subjectEnvNames = provisionedRoute ? config.subject.env ?? [] : [];
|
|
1218
|
+
const actor = config.actors[0];
|
|
1219
|
+
const actorType = actor?.type ?? "";
|
|
1220
|
+
const fail = (code, message, actorLabel) => ({
|
|
1221
|
+
schema: CUA_ACTOR_LAB_SCHEMA,
|
|
1222
|
+
ok: false,
|
|
1223
|
+
cwd,
|
|
1224
|
+
labId: config.id,
|
|
1225
|
+
actor: actorLabel ?? actorType,
|
|
1226
|
+
appUrl,
|
|
1227
|
+
dryRun,
|
|
1228
|
+
runId: options.runId ?? "not-created",
|
|
1229
|
+
lanes: [],
|
|
1230
|
+
warnings: [],
|
|
1231
|
+
error: { code, message }
|
|
1232
|
+
});
|
|
1233
|
+
// Resolve the actor through the registry — the parse layer validated this, but the engine fails
|
|
1234
|
+
// closed rather than trusting a config that arrived through another door.
|
|
1235
|
+
const descriptor = actorRegistry[actorType];
|
|
1236
|
+
if (!descriptor || !isCuaActorDescriptor(descriptor)) {
|
|
1237
|
+
return fail("HUMANISH_CUA_LAB_ACTOR_UNSUPPORTED", `actors[0].type "${actorType}" is not a registered computer-use actor.`);
|
|
1238
|
+
}
|
|
1239
|
+
const runSession = hooks.runSession ?? descriptor.runSession;
|
|
1240
|
+
const inProcessRoute = hooks.buildExecutor !== undefined;
|
|
1241
|
+
const localAppSubject = config.subject.source === "local-app";
|
|
1242
|
+
// Engine re-enforcement of the clone-route structure (library API surface).
|
|
1243
|
+
if (cloneRoute && (!serve || !subjectRepo || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(subjectRepo))) {
|
|
1244
|
+
return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", !serve
|
|
1245
|
+
? "clone subjects on the computer-use route require `subject.serve` (start + url) — the lab serves the app in-sandbox."
|
|
1246
|
+
: `subject.repos[0] must be an owner/repo slug (got "${subjectRepo ?? ""}").`, descriptor.id);
|
|
1247
|
+
}
|
|
1248
|
+
// Engine re-enforcement of the local-tree-route structure (library API surface): a caller
|
|
1249
|
+
// driving this function directly (bypassing parseLabConfig) still gets the same fail-closed
|
|
1250
|
+
// shape the parser enforces, naming which requirement is missing.
|
|
1251
|
+
if (localTreeRoute && (!serve || config.execution?.target !== "e2b-desktop")) {
|
|
1252
|
+
return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", !serve
|
|
1253
|
+
? "local-tree subjects on the computer-use route require `subject.serve` (start + url): the lab packs and serves the working tree in-sandbox."
|
|
1254
|
+
: "local-tree subjects require `execution.target: e2b-desktop`: the packed working tree is provisioned and served inside a hosted desktop sandbox.", descriptor.id);
|
|
1255
|
+
}
|
|
1256
|
+
// Engine re-enforcement of the state declaration (library API surface).
|
|
1257
|
+
if (config.subject.state) {
|
|
1258
|
+
const stateReason = !provisionedRoute
|
|
1259
|
+
? "`subject.state` applies only to clone subjects or local-tree subjects (the lab seeds the state it serves)."
|
|
1260
|
+
: subjectStateInvalidReason(config.subject.state, config.subject.env);
|
|
1261
|
+
if (stateReason) {
|
|
1262
|
+
return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", stateReason, descriptor.id);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
// Re-enforce the entry-target boundary (library API surface).
|
|
1266
|
+
const allowPublicTargets = config.policies?.allowPublicTargets === true;
|
|
1267
|
+
const declaredTargets = [appUrl, ...(actor?.lanes ?? []).map((lane) => lane.target).filter((target) => target !== undefined)];
|
|
1268
|
+
const entryTargetSafe = declaredTargets.every((target) => provisionedRoute || localAppSubject
|
|
1269
|
+
? isLoopbackUrl(target)
|
|
1270
|
+
: allowPublicTargets
|
|
1271
|
+
? isHttpUrl(target)
|
|
1272
|
+
: isLoopbackUrl(target));
|
|
1273
|
+
if (!entryTargetSafe) {
|
|
1274
|
+
return fail("HUMANISH_CUA_LAB_SUBJECT_UNSAFE", provisionedRoute || localAppSubject || !allowPublicTargets
|
|
1275
|
+
? "subject.appUrl and any actors[0].lanes[].target entries must be loopback (127.0.0.1 or localhost) unless policies.allowPublicTargets is set for an app-url subject."
|
|
1276
|
+
: "subject.appUrl and actors[0].lanes[].target entries must be valid http(s) URLs.", descriptor.id);
|
|
1277
|
+
}
|
|
1278
|
+
// In-process route pairing guard (boot-time, BEFORE key-gating): a custom executor needs a
|
|
1279
|
+
// custom provider too (the default OpenAI provider is vision-based and would fail closed).
|
|
1280
|
+
if (hooks.buildExecutor !== undefined && hooks.buildProvider === undefined) {
|
|
1281
|
+
return fail("HUMANISH_CUA_LAB_EXECUTOR_NO_PROVIDER", "cuaHooks.buildExecutor requires cuaHooks.buildProvider — a state-driven executor returns no screenshot, so it must be paired with a NON-vision provider (the default OpenAI computer-use provider is vision-based and would fail closed).", descriptor.id);
|
|
1282
|
+
}
|
|
1283
|
+
// local-app fail-closed (BEFORE key-gating): there is no built-in in-process driver.
|
|
1284
|
+
if (localAppSubject && !inProcessRoute) {
|
|
1285
|
+
return fail("HUMANISH_CUA_LAB_LOCAL_APP_NO_EXECUTOR", "subject.source: local-app requires a library caller to supply cuaHooks.buildExecutor + buildProvider; there is no built-in driver for an in-process JS contract. (Drive the app via runLab(..., { cuaHooks: { buildExecutor, buildProvider } }).)", descriptor.id);
|
|
1286
|
+
}
|
|
1287
|
+
// Re-enforce the fan-out cross-validation (library API surface): lanes XOR count/laneFocus,
|
|
1288
|
+
// device XOR raw resolution, cap, unique ids, allowPublicTargets+N>1, clone.fanout.
|
|
1289
|
+
const fanoutReason = cuaLaneValidationReason(config);
|
|
1290
|
+
if (fanoutReason) {
|
|
1291
|
+
return fail("HUMANISH_CUA_LAB_FANOUT_INVALID", fanoutReason, descriptor.id);
|
|
1292
|
+
}
|
|
1293
|
+
// Resolve the lane plan (pure) — the SAME table for dry-run and live.
|
|
1294
|
+
let { lanes: laneSpecs, plan } = laneSpecsAndPlan(config, {
|
|
1295
|
+
...(options.countOverride === undefined ? {} : { countOverride: options.countOverride }),
|
|
1296
|
+
env,
|
|
1297
|
+
dryRun
|
|
1298
|
+
});
|
|
1299
|
+
let laneCount = laneSpecs.length;
|
|
1300
|
+
if (laneCount > MAX_CUA_LANES) {
|
|
1301
|
+
return fail("HUMANISH_CUA_LAB_FANOUT_INVALID", `Computer-use fan-out is capped at ${MAX_CUA_LANES} lanes (resolved ${laneCount}); N concurrent paid desktops is real spend.`, descriptor.id);
|
|
1302
|
+
}
|
|
1303
|
+
if (inProcessRoute && laneCount > 1) {
|
|
1304
|
+
return fail("HUMANISH_CUA_LAB_FANOUT_INVALID", "Multi-lane fan-out is not supported on the in-process route (cuaHooks.buildExecutor) — fan-out provisions one independent E2B desktop per lane, which the in-process route deliberately skips. Run a single in-process lane, or fan out on the E2B route.", descriptor.id);
|
|
1305
|
+
}
|
|
1306
|
+
let rerunLineage;
|
|
1307
|
+
if (options.rerun) {
|
|
1308
|
+
const selected = await resolveCuaRerunSelection({
|
|
1309
|
+
cwd,
|
|
1310
|
+
config,
|
|
1311
|
+
sourceRunId: options.rerun.sourceRunId,
|
|
1312
|
+
...(options.rerun.laneIds === undefined ? {} : { laneIds: options.rerun.laneIds }),
|
|
1313
|
+
laneSpecs,
|
|
1314
|
+
plan
|
|
1315
|
+
});
|
|
1316
|
+
if (!selected.ok) {
|
|
1317
|
+
return fail("HUMANISH_CUA_LAB_RERUN_INVALID", selected.message, descriptor.id);
|
|
1318
|
+
}
|
|
1319
|
+
laneSpecs = selected.laneSpecs;
|
|
1320
|
+
plan = selected.plan;
|
|
1321
|
+
laneCount = laneSpecs.length;
|
|
1322
|
+
rerunLineage = selected.rerun;
|
|
1323
|
+
}
|
|
1324
|
+
// Pre-flight plan: BEFORE any sandbox or provider call (dry-run AND live). The hook fires for
|
|
1325
|
+
// every N (observable + testable); the stderr table prints for fan-out (N>1) so single-lane
|
|
1326
|
+
// runs stay as quiet as they always were.
|
|
1327
|
+
if (laneCount > 1) {
|
|
1328
|
+
emitPreflightPlan(plan, config.id);
|
|
1329
|
+
}
|
|
1330
|
+
hooks.onPreflight?.(plan);
|
|
1331
|
+
await assertPreparedSelectedOutputDirectory(projectRoot);
|
|
1332
|
+
// Read keys once into locals (names only; values never logged or persisted).
|
|
1333
|
+
const openaiApiKey = env.OPENAI_API_KEY?.trim() ?? "";
|
|
1334
|
+
const e2bApiKey = env.E2B_API_KEY?.trim() ?? "";
|
|
1335
|
+
// Literal scrubber for every known provisioned value (no secret "shape" to pattern-match).
|
|
1336
|
+
const knownSecretValues = [
|
|
1337
|
+
openaiApiKey,
|
|
1338
|
+
e2bApiKey,
|
|
1339
|
+
...subjectEnvNames.map((name) => env[name] ?? "")
|
|
1340
|
+
].filter((value) => value.length >= 4);
|
|
1341
|
+
const scrubKnownValues = (text) => knownSecretValues.reduce((current, value) => current.split(value).join("[REDACTED_SECRET]"), text);
|
|
1342
|
+
const redactRepoLabel = config.policies?.redactRepos ?? subjectEnvNames.includes("GITHUB_TOKEN");
|
|
1343
|
+
const publicRepo = cloneRoute && subjectRepo ? (redactRepoLabel ? "repo-01" : subjectRepo) : undefined;
|
|
1344
|
+
const hasGithubToken = subjectEnvNames.includes("GITHUB_TOKEN");
|
|
1345
|
+
// Key-gating is route-aware: the in-process route uses the caller's OWN model + executor.
|
|
1346
|
+
if (!dryRun && !inProcessRoute) {
|
|
1347
|
+
const missingKeys = [
|
|
1348
|
+
...(openaiApiKey ? [] : ["OPENAI_API_KEY"]),
|
|
1349
|
+
...(e2bApiKey ? [] : ["E2B_API_KEY"])
|
|
1350
|
+
];
|
|
1351
|
+
if (missingKeys.length > 0) {
|
|
1352
|
+
return fail("HUMANISH_CUA_LAB_KEYS_MISSING", `Live computer-use labs need ${missingKeys.join(" and ")} in the environment (pass them via --env-file; values are never persisted).`, descriptor.id);
|
|
1353
|
+
}
|
|
1354
|
+
const missingSubjectEnv = subjectEnvNames.filter((name) => !env[name]?.trim());
|
|
1355
|
+
if (missingSubjectEnv.length > 0) {
|
|
1356
|
+
return fail("HUMANISH_CUA_LAB_SUBJECT_ENV_MISSING", `subject.env declares ${missingSubjectEnv.join(", ")} but the environment does not provide ${missingSubjectEnv.length === 1 ? "it" : "them"} (pass via --env-file; values are never persisted).`, descriptor.id);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const runId = options.runId ?? makeCuaRunId();
|
|
1360
|
+
const runPaths = await prepareRunArtifactPaths(cwd, runId);
|
|
1361
|
+
const artifactRoot = runPaths.absoluteRunRoot;
|
|
1362
|
+
const physicalArtifactRoot = runPaths.physicalRunRoot;
|
|
1363
|
+
const createdAt = new Date().toISOString();
|
|
1364
|
+
const timeoutMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
|
|
1365
|
+
const requestTimeoutMs = readPositiveInt(env.HUMANISH_E2B_REQUEST_TIMEOUT_MS, 60_000);
|
|
1366
|
+
const redactScreenshots = config.policies?.redactScreenshots === true;
|
|
1367
|
+
await prepareContainedOutputDirectory(runPaths, "screenshots");
|
|
1368
|
+
const source = await buildRunSource({
|
|
1369
|
+
capturedAt: createdAt,
|
|
1370
|
+
cwd,
|
|
1371
|
+
humanishSource: "present",
|
|
1372
|
+
packageName: "humanish"
|
|
1373
|
+
});
|
|
1374
|
+
// Pack the working tree ONCE per run, on the host, BEFORE any sandbox or provider call: every
|
|
1375
|
+
// fan-out lane below uploads this SAME archive, so one archiveSha256 describes every lane's
|
|
1376
|
+
// digest. Dry-run packs nothing (no fs side effects; the contract bundle carries no
|
|
1377
|
+
// archiveSha256). A packing failure fails the run closed here, before createDesktopSandbox is
|
|
1378
|
+
// ever reached.
|
|
1379
|
+
let localTreeArchive;
|
|
1380
|
+
let localTreeArchiveBuffer;
|
|
1381
|
+
if (localTreeRoute && !dryRun) {
|
|
1382
|
+
const packLocalTree = hooks.packLocalTree ?? defaultPackLocalTree;
|
|
1383
|
+
try {
|
|
1384
|
+
const packed = await packLocalTree({
|
|
1385
|
+
root: cwd,
|
|
1386
|
+
...(config.subject.localTree?.exclude === undefined ? {} : { extraExclude: config.subject.localTree.exclude }),
|
|
1387
|
+
...(config.subject.localTree?.maxArchiveBytes === undefined ? {} : { maxArchiveBytes: config.subject.localTree.maxArchiveBytes })
|
|
1388
|
+
});
|
|
1389
|
+
localTreeArchive = packed.archive;
|
|
1390
|
+
localTreeArchiveBuffer = packed.buffer;
|
|
1391
|
+
// One operator-facing line (stderr, same channel as emitPreflightPlan): what left the
|
|
1392
|
+
// host, by counts and digest only, never paths or file names.
|
|
1393
|
+
process.stderr.write(`humanish local-tree: packed ${packed.archive.fileCount} entries, ${packed.archive.totalBytes} bytes, archiveSha256 ${packed.archive.archiveSha256}`
|
|
1394
|
+
+ `${packed.archive.git ? ` (commit ${packed.archive.git.commit.slice(0, 12)}, ${packed.archive.git.dirty ? "dirty" : "clean"} working tree)` : " (not a git work tree)"}\n`);
|
|
1395
|
+
}
|
|
1396
|
+
catch (error) {
|
|
1397
|
+
return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", `local-tree packing failed: ${redactText(scrubKnownValues(toErrorMessage(error)))}`, descriptor.id);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
const deps = {
|
|
1401
|
+
config,
|
|
1402
|
+
descriptor,
|
|
1403
|
+
appUrl,
|
|
1404
|
+
cloneRoute,
|
|
1405
|
+
localTreeRoute,
|
|
1406
|
+
...(serve === undefined ? {} : { serve }),
|
|
1407
|
+
...(subjectRepo === undefined ? {} : { subjectRepo }),
|
|
1408
|
+
subjectEnvNames,
|
|
1409
|
+
hasGithubToken,
|
|
1410
|
+
...(localTreeArchiveBuffer === undefined ? {} : { localTreeArchiveBuffer }),
|
|
1411
|
+
env,
|
|
1412
|
+
openaiApiKey,
|
|
1413
|
+
e2bApiKey,
|
|
1414
|
+
requestTimeoutMs,
|
|
1415
|
+
perLaneSandboxMs: resolvePerLaneSandboxMs(config),
|
|
1416
|
+
timeoutMs,
|
|
1417
|
+
laneCount,
|
|
1418
|
+
artifactRoot: runPaths,
|
|
1419
|
+
redactScreenshots,
|
|
1420
|
+
scrubKnownValues,
|
|
1421
|
+
runSession,
|
|
1422
|
+
hooks: liveHooks
|
|
1423
|
+
};
|
|
1424
|
+
const inProgressLaneSubjects = laneSpecs.map(() => laneSubjectProjection({
|
|
1425
|
+
cloneRoute,
|
|
1426
|
+
localTreeRoute,
|
|
1427
|
+
...(publicRepo === undefined ? {} : { publicRepo }),
|
|
1428
|
+
subjectEnvNames,
|
|
1429
|
+
...(localTreeArchive === undefined ? {} : { localTreeArchive }),
|
|
1430
|
+
subjectState: resolveSubjectState({
|
|
1431
|
+
declared: provisionedRoute ? config.subject.state : undefined,
|
|
1432
|
+
dryRun: false,
|
|
1433
|
+
executed: []
|
|
1434
|
+
})
|
|
1435
|
+
}));
|
|
1436
|
+
const inProgressAggregateSubject = inProgressLaneSubjects[0];
|
|
1437
|
+
const inProgressProvenance = subjectProvenanceArg(inProgressAggregateSubject, publicRepo, subjectEnvNames);
|
|
1438
|
+
if (!dryRun && options.onObserverReady) {
|
|
1439
|
+
const inProgressBundle = laneCount === 1 && rerunLineage === undefined
|
|
1440
|
+
? buildSingleLaneBundle({
|
|
1441
|
+
spec: laneSpecs[0],
|
|
1442
|
+
outcome: undefined,
|
|
1443
|
+
descriptor,
|
|
1444
|
+
appUrl: laneSpecs[0].targetUrl ?? appUrl,
|
|
1445
|
+
createdAt,
|
|
1446
|
+
dryRun: false,
|
|
1447
|
+
config,
|
|
1448
|
+
runId,
|
|
1449
|
+
source,
|
|
1450
|
+
redactScreenshots,
|
|
1451
|
+
inProgress: true,
|
|
1452
|
+
...(inProgressProvenance === undefined ? {} : { subjectProvenance: inProgressProvenance }),
|
|
1453
|
+
inProcessRoute,
|
|
1454
|
+
localAppSubject
|
|
1455
|
+
})
|
|
1456
|
+
: buildCuaFanoutBundle({
|
|
1457
|
+
specs: laneSpecs,
|
|
1458
|
+
laneSubjects: inProgressLaneSubjects,
|
|
1459
|
+
aggregateSubject: inProgressAggregateSubject,
|
|
1460
|
+
descriptor,
|
|
1461
|
+
appUrl,
|
|
1462
|
+
createdAt,
|
|
1463
|
+
dryRun: false,
|
|
1464
|
+
config,
|
|
1465
|
+
runId,
|
|
1466
|
+
source,
|
|
1467
|
+
plan,
|
|
1468
|
+
...(rerunLineage === undefined ? {} : { rerun: rerunLineage }),
|
|
1469
|
+
cloneRoute,
|
|
1470
|
+
localTreeRoute,
|
|
1471
|
+
...(publicRepo === undefined ? {} : { publicRepo }),
|
|
1472
|
+
subjectEnvNames,
|
|
1473
|
+
inProgress: true
|
|
1474
|
+
});
|
|
1475
|
+
await writeCuaRunArtifacts(inProgressBundle, createdAt, runPaths);
|
|
1476
|
+
liveObserver = observerResultForCuaArtifacts(cwd, runId, artifactRoot, [
|
|
1477
|
+
"Live CUA Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
|
|
1478
|
+
]);
|
|
1479
|
+
await options.onObserverReady(liveObserver);
|
|
1480
|
+
}
|
|
1481
|
+
// Run lanes (dry-run runs none). In-process is always one lane.
|
|
1482
|
+
let outcomes;
|
|
1483
|
+
let failFastReason;
|
|
1484
|
+
if (!dryRun) {
|
|
1485
|
+
if (inProcessRoute) {
|
|
1486
|
+
outcomes = [await runInProcessLane(laneSpecs[0], deps)];
|
|
1487
|
+
}
|
|
1488
|
+
else if (laneCount === 1) {
|
|
1489
|
+
outcomes = [await runCuaLane(laneSpecs[0], deps)];
|
|
1490
|
+
}
|
|
1491
|
+
else {
|
|
1492
|
+
const ran = await runCuaLanes(laneSpecs, deps, plan.concurrency);
|
|
1493
|
+
outcomes = ran.outcomes;
|
|
1494
|
+
failFastReason = ran.failFastReason;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
// Per-lane subject projections (invariant 5).
|
|
1498
|
+
const laneSubjects = laneSpecs.map((_spec, index) => {
|
|
1499
|
+
const outcome = outcomes?.[index];
|
|
1500
|
+
const subjectState = resolveSubjectState({
|
|
1501
|
+
declared: provisionedRoute ? config.subject.state : undefined,
|
|
1502
|
+
dryRun,
|
|
1503
|
+
executed: outcome?.stateStepRecords ?? []
|
|
1504
|
+
});
|
|
1505
|
+
return laneSubjectProjection({
|
|
1506
|
+
cloneRoute,
|
|
1507
|
+
localTreeRoute,
|
|
1508
|
+
...(publicRepo === undefined ? {} : { publicRepo }),
|
|
1509
|
+
subjectEnvNames,
|
|
1510
|
+
...(outcome?.subjectCommit === undefined ? {} : { subjectCommit: outcome.subjectCommit }),
|
|
1511
|
+
...(localTreeArchive === undefined ? {} : { localTreeArchive }),
|
|
1512
|
+
subjectState
|
|
1513
|
+
});
|
|
1514
|
+
});
|
|
1515
|
+
// Aggregate subject (top-level + bundle): unanimity-gated commit (+ divergence warning) on
|
|
1516
|
+
// the clone route. Local-tree lanes all pack from the SAME once-per-run archive, so every
|
|
1517
|
+
// lane's projection already carries the identical archiveSha256/commit/dirty: the
|
|
1518
|
+
// `first.source !== "clone"` branch below returns it directly, with no unanimity math needed
|
|
1519
|
+
// (there is nothing that could diverge).
|
|
1520
|
+
const aggregateWarnings = [];
|
|
1521
|
+
const aggregateSubject = (() => {
|
|
1522
|
+
const first = laneSubjects[0];
|
|
1523
|
+
if (first.source !== "clone") {
|
|
1524
|
+
return first;
|
|
1525
|
+
}
|
|
1526
|
+
const commits = (outcomes ?? []).map((outcome) => outcome.subjectCommit).filter((commit) => commit !== undefined);
|
|
1527
|
+
const unanimous = !dryRun && commits.length === laneCount && new Set(commits).size === 1;
|
|
1528
|
+
if (!dryRun && laneCount > 1 && new Set(commits).size > 1) {
|
|
1529
|
+
aggregateWarnings.push("Fan-out lanes resolved DIVERGENT subject commits — the top-level subject.commit is omitted; see per-lane provenance in result.lanes for each lane's pinned commit.");
|
|
1530
|
+
}
|
|
1531
|
+
// Build without commit, then add it only when unanimous (avoids an explicit commit:undefined
|
|
1532
|
+
// under exactOptionalPropertyTypes).
|
|
1533
|
+
return {
|
|
1534
|
+
source: "clone",
|
|
1535
|
+
...(first.repo === undefined ? {} : { repo: first.repo }),
|
|
1536
|
+
...(first.envNames === undefined ? {} : { envNames: first.envNames }),
|
|
1537
|
+
state: first.state,
|
|
1538
|
+
...(unanimous && commits[0] !== undefined ? { commit: commits[0] } : {})
|
|
1539
|
+
};
|
|
1540
|
+
})();
|
|
1541
|
+
const finalProvenance = subjectProvenanceArg(aggregateSubject, publicRepo, subjectEnvNames);
|
|
1542
|
+
const bundle = laneCount === 1 && rerunLineage === undefined
|
|
1543
|
+
? buildSingleLaneBundle({
|
|
1544
|
+
spec: laneSpecs[0],
|
|
1545
|
+
outcome: outcomes?.[0],
|
|
1546
|
+
descriptor,
|
|
1547
|
+
appUrl: laneSpecs[0].targetUrl ?? appUrl,
|
|
1548
|
+
createdAt,
|
|
1549
|
+
dryRun,
|
|
1550
|
+
config,
|
|
1551
|
+
runId,
|
|
1552
|
+
source,
|
|
1553
|
+
redactScreenshots,
|
|
1554
|
+
...(finalProvenance === undefined ? {} : { subjectProvenance: finalProvenance }),
|
|
1555
|
+
inProcessRoute,
|
|
1556
|
+
localAppSubject
|
|
1557
|
+
})
|
|
1558
|
+
: buildCuaFanoutBundle({
|
|
1559
|
+
specs: laneSpecs,
|
|
1560
|
+
...(outcomes === undefined ? {} : { outcomes }),
|
|
1561
|
+
laneSubjects,
|
|
1562
|
+
aggregateSubject,
|
|
1563
|
+
descriptor,
|
|
1564
|
+
appUrl,
|
|
1565
|
+
createdAt,
|
|
1566
|
+
dryRun,
|
|
1567
|
+
config,
|
|
1568
|
+
runId,
|
|
1569
|
+
source,
|
|
1570
|
+
plan,
|
|
1571
|
+
...(rerunLineage === undefined ? {} : { rerun: rerunLineage }),
|
|
1572
|
+
...(failFastReason === undefined ? {} : { failFastReason }),
|
|
1573
|
+
cloneRoute,
|
|
1574
|
+
localTreeRoute,
|
|
1575
|
+
...(publicRepo === undefined ? {} : { publicRepo }),
|
|
1576
|
+
subjectEnvNames
|
|
1577
|
+
});
|
|
1578
|
+
const adapterWarnings = [];
|
|
1579
|
+
await applyBrowserAdapterHooks({
|
|
1580
|
+
hooks,
|
|
1581
|
+
bundle,
|
|
1582
|
+
context: {
|
|
1583
|
+
bundle,
|
|
1584
|
+
runDir: physicalArtifactRoot,
|
|
1585
|
+
labId: config.id,
|
|
1586
|
+
runId,
|
|
1587
|
+
actor: descriptor.id,
|
|
1588
|
+
backend: "cua",
|
|
1589
|
+
dryRun,
|
|
1590
|
+
laneCount
|
|
1591
|
+
},
|
|
1592
|
+
sanitize: (text) => redactText(scrubKnownValues(text)),
|
|
1593
|
+
warnings: adapterWarnings,
|
|
1594
|
+
hookLabel: "cuaHooks"
|
|
1595
|
+
});
|
|
1596
|
+
await writeCuaRunArtifacts(bundle, createdAt, runPaths);
|
|
1597
|
+
const observer = await render(cwd, runId, { open: options.open === true });
|
|
1598
|
+
if (observer.ok && runtimeStreamUrls.length > 0) {
|
|
1599
|
+
attachObserverRuntimeStreamUrls(observer, runtimeStreamUrls);
|
|
1600
|
+
}
|
|
1601
|
+
// Lane-level pass: dry-run lanes are contract-ok; live lanes need a passed, engaged session.
|
|
1602
|
+
const laneOk = (outcome) => laneOutcomeOk(outcome, dryRun);
|
|
1603
|
+
const allLanesOk = laneSpecs.every((_, index) => laneOk(outcomes?.[index]));
|
|
1604
|
+
const adapterFailure = adapterScoreFailureMessage(bundle);
|
|
1605
|
+
const ok = observer.ok && allLanesOk && adapterFailure === undefined;
|
|
1606
|
+
const laneWarnings = (outcomes ?? []).flatMap((outcome) => outcome.warnings);
|
|
1607
|
+
const warnings = [...laneWarnings, ...aggregateWarnings, ...adapterWarnings, ...observer.warnings];
|
|
1608
|
+
const laneResults = laneSpecs.map((spec, index) => toLaneResult(spec, outcomes?.[index], laneSubjects[index], dryRun));
|
|
1609
|
+
const laneSummary = buildLaneSummary(outcomes, laneCount, plan, dryRun);
|
|
1610
|
+
const firstOutcome = outcomes?.[0];
|
|
1611
|
+
const errorResult = (() => {
|
|
1612
|
+
if (ok)
|
|
1613
|
+
return undefined;
|
|
1614
|
+
if (adapterFailure !== undefined) {
|
|
1615
|
+
return {
|
|
1616
|
+
code: "HUMANISH_CUA_LAB_FAILED",
|
|
1617
|
+
message: adapterFailure
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
if (laneCount === 1) {
|
|
1621
|
+
const outcome = firstOutcome;
|
|
1622
|
+
return {
|
|
1623
|
+
code: outcome?.failureCode ?? "HUMANISH_CUA_LAB_FAILED",
|
|
1624
|
+
message: outcome?.sessionError
|
|
1625
|
+
?? (outcome?.noEngagement
|
|
1626
|
+
? "Actor took no actions and produced no message (likely a blank/still-loading screen); not a credible goal_satisfied."
|
|
1627
|
+
: observer.ok
|
|
1628
|
+
? outcome?.session?.completionReason === "harness_error"
|
|
1629
|
+
? `Computer-use session ended with a harness error: ${outcome.session.reason}`
|
|
1630
|
+
: outcome?.session?.status !== "passed"
|
|
1631
|
+
? `Computer-use session ended with ${outcome?.session?.status ?? "unknown"}: ${outcome?.session?.reason ?? "no terminal reason"}`
|
|
1632
|
+
: "Computer-use lab did not produce a terminal session."
|
|
1633
|
+
: observer.error?.message ?? "Observer failed for the computer-use lab run.")
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
const failingLane = (outcomes ?? []).find((outcome) => !laneOk(outcome));
|
|
1637
|
+
const geometryLane = (outcomes ?? []).find((outcome) => outcome.failureCode === "HUMANISH_CUA_LAB_DEVICE_GEOMETRY");
|
|
1638
|
+
const code = geometryLane?.failureCode ?? "HUMANISH_CUA_LAB_FAILED";
|
|
1639
|
+
return {
|
|
1640
|
+
code,
|
|
1641
|
+
message: observer.ok
|
|
1642
|
+
? `Fan-out run failed: ${laneSummary.passed}/${laneCount} lane(s) passed (${laneSummary.skipped} skipped, ${laneSummary.harnessErrors} harness error(s), ${laneSummary.hollow} hollow)${failingLane?.sessionError ? `; first failure: ${failingLane.sessionError}` : ""}.`
|
|
1643
|
+
: observer.error?.message ?? "Observer failed for the computer-use fan-out run."
|
|
1644
|
+
};
|
|
1645
|
+
})();
|
|
1646
|
+
return {
|
|
1647
|
+
schema: CUA_ACTOR_LAB_SCHEMA,
|
|
1648
|
+
ok,
|
|
1649
|
+
cwd,
|
|
1650
|
+
labId: config.id,
|
|
1651
|
+
actor: descriptor.id,
|
|
1652
|
+
appUrl,
|
|
1653
|
+
dryRun,
|
|
1654
|
+
runId,
|
|
1655
|
+
...(firstOutcome?.session
|
|
1656
|
+
? {
|
|
1657
|
+
session: {
|
|
1658
|
+
status: firstOutcome.session.status,
|
|
1659
|
+
completionReason: firstOutcome.session.completionReason,
|
|
1660
|
+
reason: firstOutcome.session.reason,
|
|
1661
|
+
screenshots: firstOutcome.screenshots.length
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
: {}),
|
|
1665
|
+
...(firstOutcome?.sandboxId
|
|
1666
|
+
? { sandbox: { sandboxId: firstOutcome.sandboxId, killed: firstOutcome.killed, streamUrlPresent: firstOutcome.streamUrlPresent } }
|
|
1667
|
+
: {}),
|
|
1668
|
+
subject: aggregateSubject,
|
|
1669
|
+
plan,
|
|
1670
|
+
lanes: laneResults,
|
|
1671
|
+
laneSummary,
|
|
1672
|
+
...(rerunLineage === undefined ? {} : { rerun: rerunLineage }),
|
|
1673
|
+
observer,
|
|
1674
|
+
warnings,
|
|
1675
|
+
...(errorResult === undefined ? {} : { error: errorResult })
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
/** Aggregate lane counts for the result projection. */
|
|
1679
|
+
function buildLaneSummary(outcomes, laneCount, plan, dryRun) {
|
|
1680
|
+
if (dryRun || !outcomes) {
|
|
1681
|
+
return {
|
|
1682
|
+
strategy: CUA_FANOUT_STRATEGY,
|
|
1683
|
+
total: laneCount,
|
|
1684
|
+
passed: 0,
|
|
1685
|
+
skipped: 0,
|
|
1686
|
+
harnessErrors: 0,
|
|
1687
|
+
hollow: 0,
|
|
1688
|
+
concurrency: plan.concurrency,
|
|
1689
|
+
waves: plan.waves
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
let passed = 0;
|
|
1693
|
+
let skipped = 0;
|
|
1694
|
+
let harnessErrors = 0;
|
|
1695
|
+
let hollow = 0;
|
|
1696
|
+
for (const outcome of outcomes) {
|
|
1697
|
+
if (outcome.skippedReason !== undefined) {
|
|
1698
|
+
skipped += 1;
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
if (outcome.harnessError)
|
|
1702
|
+
harnessErrors += 1;
|
|
1703
|
+
if (outcome.noEngagement)
|
|
1704
|
+
hollow += 1;
|
|
1705
|
+
if (laneOutcomeOk(outcome, dryRun))
|
|
1706
|
+
passed += 1;
|
|
1707
|
+
}
|
|
1708
|
+
return {
|
|
1709
|
+
strategy: CUA_FANOUT_STRATEGY,
|
|
1710
|
+
total: laneCount,
|
|
1711
|
+
passed,
|
|
1712
|
+
skipped,
|
|
1713
|
+
harnessErrors,
|
|
1714
|
+
hollow,
|
|
1715
|
+
concurrency: plan.concurrency,
|
|
1716
|
+
waves: plan.waves
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
async function writeCuaRunArtifacts(bundle, updatedAt, preparedRunPaths) {
|
|
1720
|
+
const runPaths = await validatePreparedRunArtifactPaths(preparedRunPaths);
|
|
1721
|
+
const publicBundle = {
|
|
1722
|
+
...bundle,
|
|
1723
|
+
cwd: PUBLIC_TARGET_CWD
|
|
1724
|
+
};
|
|
1725
|
+
await writeContainedOutputFile(runPaths, "run.json", `${JSON.stringify(publicBundle, null, 2)}\n`, "utf8");
|
|
1726
|
+
await writeContainedOutputFile(runPaths, "review.json", `${JSON.stringify(publicBundle.review, null, 2)}\n`, "utf8");
|
|
1727
|
+
await writeContainedOutputFile(runPaths, "review.md", renderCuaReviewMarkdown(publicBundle), "utf8");
|
|
1728
|
+
await writeContainedOutputFile(runPaths, "events.ndjson", `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8");
|
|
1729
|
+
await writeContainedOutputFile(runPaths, "observer/observer-data.json", `${JSON.stringify(buildObserverData(publicBundle), null, 2)}\n`, "utf8");
|
|
1730
|
+
await writePreparedRunLatestPointer(runPaths, `${JSON.stringify({
|
|
1731
|
+
schema: "humanish.latest-run.v1",
|
|
1732
|
+
runId: publicBundle.runId,
|
|
1733
|
+
path: runPaths.relativeRunRoot,
|
|
1734
|
+
updatedAt
|
|
1735
|
+
}, null, 2)}\n`, "utf8");
|
|
1736
|
+
}
|
|
1737
|
+
function observerResultForCuaArtifacts(cwd, runId, artifactRoot, warnings = []) {
|
|
1738
|
+
const observerPath = path.join(artifactRoot, "observer", "index.html");
|
|
1739
|
+
const observerDataPath = path.join(artifactRoot, "observer", "observer-data.json");
|
|
1740
|
+
const eventsPath = path.join(artifactRoot, "events.ndjson");
|
|
1741
|
+
return {
|
|
1742
|
+
schema: "humanish.observer-result.v1",
|
|
1743
|
+
ok: true,
|
|
1744
|
+
cwd,
|
|
1745
|
+
run: runId,
|
|
1746
|
+
observerPath: path.relative(cwd, observerPath),
|
|
1747
|
+
observerDataPath: path.relative(cwd, observerDataPath),
|
|
1748
|
+
eventsPath: path.relative(cwd, eventsPath),
|
|
1749
|
+
observerUrl: pathToFileURL(observerPath).href,
|
|
1750
|
+
bundlePath: path.join(artifactRoot, "run.json"),
|
|
1751
|
+
opened: false,
|
|
1752
|
+
warnings
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
/** Build the N=1 bundle via the unchanged buildCuaBundle (byte-stable). */
|
|
1756
|
+
function buildSingleLaneBundle(args) {
|
|
1757
|
+
const { spec, outcome, config } = args;
|
|
1758
|
+
return buildCuaBundle({
|
|
1759
|
+
actorId: args.descriptor.id,
|
|
1760
|
+
appUrl: args.appUrl,
|
|
1761
|
+
laneId: spec.laneId,
|
|
1762
|
+
...(spec.actorType === undefined ? {} : { actorType: spec.actorType }),
|
|
1763
|
+
...(spec.surface === undefined ? {} : { surface: spec.surface }),
|
|
1764
|
+
...(spec.caseGroup === undefined ? {} : { caseGroup: spec.caseGroup }),
|
|
1765
|
+
createdAt: args.createdAt,
|
|
1766
|
+
dryRun: args.dryRun,
|
|
1767
|
+
labId: config.id,
|
|
1768
|
+
...(config.title ? { labTitle: config.title } : {}),
|
|
1769
|
+
mission: spec.instructions,
|
|
1770
|
+
persona: spec.persona,
|
|
1771
|
+
resolution: spec.resolution,
|
|
1772
|
+
deviceScaleFactor: spec.devicePreset.deviceScaleFactor,
|
|
1773
|
+
isMobile: spec.devicePreset.isMobile,
|
|
1774
|
+
runId: args.runId,
|
|
1775
|
+
screenshots: outcome?.screenshots ?? [],
|
|
1776
|
+
captureRedaction: args.redactScreenshots ? "blurred" : "raw",
|
|
1777
|
+
...(outcome?.session ? { session: outcome.session } : {}),
|
|
1778
|
+
...(outcome?.sessionError ? { sessionError: outcome.sessionError } : {}),
|
|
1779
|
+
source: args.source,
|
|
1780
|
+
...(args.inProgress === undefined ? {} : { inProgress: args.inProgress }),
|
|
1781
|
+
...(args.subjectProvenance === undefined ? {} : { subjectProvenance: args.subjectProvenance }),
|
|
1782
|
+
...(config.execution?.desktop?.template === undefined ? {} : { desktopTemplate: config.execution.desktop.template }),
|
|
1783
|
+
...(outcome?.desktopBrowser === undefined ? {} : { desktopBrowser: outcome.desktopBrowser }),
|
|
1784
|
+
providerResources: providerResourcesForOutcome({
|
|
1785
|
+
outcome,
|
|
1786
|
+
createdAt: args.createdAt,
|
|
1787
|
+
simId: spec.simId,
|
|
1788
|
+
streamId: spec.streamId,
|
|
1789
|
+
laneId: spec.laneId
|
|
1790
|
+
}),
|
|
1791
|
+
...(args.localAppSubject || args.inProcessRoute ? { entryKind: "local-app" } : {}),
|
|
1792
|
+
...(outcome?.session ? { traceArtifactPath: spec.traceArtifactPath } : {}),
|
|
1793
|
+
phaseEvents: outcome?.phaseRecords ?? []
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Shared post-populate provisioning pipeline (clone AND local-tree routes): (install) ->
|
|
1798
|
+
* state(before-build) -> (build) -> state(before-start) -> detached start -> readiness probe ->
|
|
1799
|
+
* state(after-ready). Both provisioning routes populate SUBJECT_DIR by different means (git
|
|
1800
|
+
* clone vs. upload+extract) and then run this identical pipeline unchanged.
|
|
1801
|
+
*
|
|
1802
|
+
* State steps run through the same detached primitive as serve steps (author-trusted, the
|
|
1803
|
+
* "serve commands are author-trusted" corollary) under the reserved `subject-state-<name>`
|
|
1804
|
+
* label prefix, so a step name can never collide with subject-clone/subject-extract/install/
|
|
1805
|
+
* build/start. after-ready steps complete BEFORE the caller opens the browser: the actor never
|
|
1806
|
+
* drives a half-seeded subject and seeding never eats the session budget.
|
|
1807
|
+
*/
|
|
1808
|
+
async function runSubjectServePipeline(desktop, args) {
|
|
1809
|
+
const timers = {
|
|
1810
|
+
...(args.now === undefined ? {} : { now: args.now }),
|
|
1811
|
+
...(args.sleep === undefined ? {} : { sleep: args.sleep })
|
|
1812
|
+
};
|
|
1813
|
+
const now = args.now ?? Date.now;
|
|
1814
|
+
const refresh = args.onPhaseComplete ?? (() => Promise.resolve());
|
|
1815
|
+
const stateSteps = args.state?.seed ?? [];
|
|
1816
|
+
const runStateSteps = async (when) => {
|
|
1817
|
+
const steps = stateSteps.filter((step) => (step.when ?? "before-start") === when);
|
|
1818
|
+
if (steps.length === 0) {
|
|
1819
|
+
// No declared steps for this group: no boundary to report (avoids empty-group noise on
|
|
1820
|
+
// every run, since before-build/before-start/after-ready are always called).
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
const groupStartedAt = now();
|
|
1824
|
+
emitPhaseStarted(args.onPhase, now, `state.${when}`, `running subject state seed steps (${when})`);
|
|
1825
|
+
for (const step of steps) {
|
|
1826
|
+
const stepTimeoutMs = step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS;
|
|
1827
|
+
const startedAt = now();
|
|
1828
|
+
const result = await runDetachedStep(desktop, {
|
|
1829
|
+
name: `subject-state-${step.name}`,
|
|
1830
|
+
command: step.command,
|
|
1831
|
+
cwd: SUBJECT_DIR,
|
|
1832
|
+
timeoutMs: stepTimeoutMs,
|
|
1833
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
1834
|
+
...timers
|
|
1835
|
+
});
|
|
1836
|
+
args.onStateStep?.({
|
|
1837
|
+
name: step.name,
|
|
1838
|
+
when,
|
|
1839
|
+
// Digest only (sha256-16): the command text never persists: the lab YAML in the
|
|
1840
|
+
// consumer's repo is the plaintext source of truth.
|
|
1841
|
+
commandDigest: commandDigestOf(step.command),
|
|
1842
|
+
ok: result.ok,
|
|
1843
|
+
...(result.exitCode === undefined ? {} : { exitCode: result.exitCode }),
|
|
1844
|
+
...(result.timedOut ? { timedOut: true } : {}),
|
|
1845
|
+
durationMs: Math.max(0, now() - startedAt)
|
|
1846
|
+
});
|
|
1847
|
+
if (!result.ok) {
|
|
1848
|
+
emitPhaseCompleted(args.onPhase, now, groupStartedAt, `state.${when}`, false, `subject state seed steps failed (${when})`);
|
|
1849
|
+
// Fail closed with the existing scrub-before-truncate tail chain: literal scrub of
|
|
1850
|
+
// every provisioned value PRE-truncation, then pattern redaction + cap in tailOf.
|
|
1851
|
+
throw new Error(`subject state step "${step.name}" ${result.timedOut ? `timed out after ${stepTimeoutMs}ms` : `failed (exit ${result.exitCode})`}: ${tailOf(args.scrub(result.logTail))}`);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
emitPhaseCompleted(args.onPhase, now, groupStartedAt, `state.${when}`, true, `subject state seed steps complete (${when})`);
|
|
1855
|
+
};
|
|
1856
|
+
if (args.serve.install) {
|
|
1857
|
+
const installStartedAt = now();
|
|
1858
|
+
emitPhaseStarted(args.onPhase, now, "install", "installing subject dependencies");
|
|
1859
|
+
const install = await runDetachedStep(desktop, {
|
|
1860
|
+
name: "subject-install",
|
|
1861
|
+
command: args.serve.install,
|
|
1862
|
+
cwd: SUBJECT_DIR,
|
|
1863
|
+
timeoutMs: args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS,
|
|
1864
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
1865
|
+
...timers
|
|
1866
|
+
});
|
|
1867
|
+
emitPhaseCompleted(args.onPhase, now, installStartedAt, "install", install.ok, install.ok ? "subject dependencies installed" : "subject install failed");
|
|
1868
|
+
if (!install.ok) {
|
|
1869
|
+
throw new Error(`subject install ${install.timedOut ? "timed out" : `failed (exit ${install.exitCode})`}: ${tailOf(args.scrub(install.logTail))}`);
|
|
1870
|
+
}
|
|
1871
|
+
await refresh();
|
|
1872
|
+
}
|
|
1873
|
+
// before-build: after install, before build (builds that read seeded state, e.g. SSG).
|
|
1874
|
+
// When no build is declared this simply precedes start: equivalent to before-start.
|
|
1875
|
+
await runStateSteps("before-build");
|
|
1876
|
+
await refresh();
|
|
1877
|
+
if (args.serve.build) {
|
|
1878
|
+
const buildStartedAt = now();
|
|
1879
|
+
emitPhaseStarted(args.onPhase, now, "build", "building subject");
|
|
1880
|
+
const build = await runDetachedStep(desktop, {
|
|
1881
|
+
name: "subject-build",
|
|
1882
|
+
command: args.serve.build,
|
|
1883
|
+
cwd: SUBJECT_DIR,
|
|
1884
|
+
timeoutMs: args.serve.buildTimeoutMs ?? BUILD_TIMEOUT_MS,
|
|
1885
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
1886
|
+
...timers
|
|
1887
|
+
});
|
|
1888
|
+
emitPhaseCompleted(args.onPhase, now, buildStartedAt, "build", build.ok, build.ok ? "subject build complete" : "subject build failed");
|
|
1889
|
+
if (!build.ok) {
|
|
1890
|
+
throw new Error(`subject build ${build.timedOut ? "timed out" : `failed (exit ${build.exitCode})`}: ${tailOf(args.scrub(build.logTail))}`);
|
|
1891
|
+
}
|
|
1892
|
+
await refresh();
|
|
1893
|
+
}
|
|
1894
|
+
// before-start (the default phase): migrations, SQL/file fixtures, an in-sandbox DB server
|
|
1895
|
+
// (`sudo service postgresql start && pg_isready` is a bounded step; the daemon it forks is
|
|
1896
|
+
// reclaimed by the sandbox lifecycle like everything else).
|
|
1897
|
+
await runStateSteps("before-start");
|
|
1898
|
+
await refresh();
|
|
1899
|
+
await startDetachedProcess(desktop, {
|
|
1900
|
+
name: "subject-start",
|
|
1901
|
+
command: args.serve.start,
|
|
1902
|
+
cwd: SUBJECT_DIR,
|
|
1903
|
+
requestTimeoutMs: args.requestTimeoutMs
|
|
1904
|
+
});
|
|
1905
|
+
// Fire-and-forget: startDetachedProcess never waits for the long-lived server to exit, so
|
|
1906
|
+
// there is no matching completed event here (no ok/durationMs to report yet); readiness is
|
|
1907
|
+
// the next boundary.
|
|
1908
|
+
args.onPhase?.({ at: isoNow(now), type: "cua-lab.subject.serve.started", message: "subject server launched (detached)" });
|
|
1909
|
+
const readyStartedAt = now();
|
|
1910
|
+
emitPhaseStarted(args.onPhase, now, "ready", "waiting for subject to become ready");
|
|
1911
|
+
const ready = await probeUrl(desktop, args.serve.url, {
|
|
1912
|
+
timeoutMs: args.serve.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
1913
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
1914
|
+
...timers
|
|
1915
|
+
});
|
|
1916
|
+
emitPhaseCompleted(args.onPhase, now, readyStartedAt, "ready", ready, ready ? "subject is ready" : "subject did not become ready in time");
|
|
1917
|
+
if (!ready) {
|
|
1918
|
+
const startLog = await readDetachedLog(desktop, "subject-start", args.requestTimeoutMs).catch(() => "");
|
|
1919
|
+
throw new Error(`subject did not answer at ${args.serve.url} within ${args.serve.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms; server log tail: ${tailOf(args.scrub(startLog))}`);
|
|
1920
|
+
}
|
|
1921
|
+
// after-ready: fixture loading through the RUNNING app (loopback curl from in-sandbox:
|
|
1922
|
+
// steps are author-trusted provisioning, not actors, so no new URL policy surface). These
|
|
1923
|
+
// complete before the caller opens the browser and the session timer starts.
|
|
1924
|
+
await runStateSteps("after-ready");
|
|
1925
|
+
await refresh();
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Provision a clone subject inside the sandbox: clone → the shared serve pipeline
|
|
1929
|
+
* (install → state(before-build) → build → state(before-start) → start → readiness
|
|
1930
|
+
* probe → state(after-ready)). Returns the latest subject HEAD after successful
|
|
1931
|
+
* provisioning. Throws (with a capped log tail for the caller to redact) on any failing step:
|
|
1932
|
+
* the lab persists that as a failed-evidence bundle.
|
|
1933
|
+
*
|
|
1934
|
+
* Auth: when GITHUB_TOKEN is among the declared subject env names, the clone authenticates
|
|
1935
|
+
* via an Authorization header computed IN-SANDBOX from the provisioned env: the token never
|
|
1936
|
+
* appears in the script text, the process argv beyond the transient git call, the clone URL,
|
|
1937
|
+
* or .git/config.
|
|
1938
|
+
*/
|
|
1939
|
+
export async function provisionCloneSubject(desktop, args) {
|
|
1940
|
+
const timers = {
|
|
1941
|
+
...(args.now === undefined ? {} : { now: args.now }),
|
|
1942
|
+
...(args.sleep === undefined ? {} : { sleep: args.sleep })
|
|
1943
|
+
};
|
|
1944
|
+
const now = args.now ?? Date.now;
|
|
1945
|
+
let latestCommit;
|
|
1946
|
+
const refreshCommit = async () => {
|
|
1947
|
+
const head = await desktop.commands.run(`git -C ${SUBJECT_DIR} rev-parse HEAD 2>/dev/null || true`, { requestTimeoutMs: args.requestTimeoutMs });
|
|
1948
|
+
const commit = (head.stdout ?? "").trim() || undefined;
|
|
1949
|
+
if (commit) {
|
|
1950
|
+
latestCommit = commit;
|
|
1951
|
+
args.onCommit?.(commit);
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
const cloneCommand = args.hasGithubToken
|
|
1955
|
+
? `auth=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0) && git -c http.extraHeader="Authorization: Basic $auth" clone --depth ${args.depth} https://github.com/${args.repo}.git ${SUBJECT_DIR}`
|
|
1956
|
+
: `git clone --depth ${args.depth} https://github.com/${args.repo}.git ${SUBJECT_DIR}`;
|
|
1957
|
+
const cloneStartedAt = now();
|
|
1958
|
+
emitPhaseStarted(args.onPhase, now, "clone", "cloning subject repository");
|
|
1959
|
+
const clone = await runDetachedStep(desktop, {
|
|
1960
|
+
name: "subject-clone",
|
|
1961
|
+
command: cloneCommand,
|
|
1962
|
+
timeoutMs: CLONE_TIMEOUT_MS,
|
|
1963
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
1964
|
+
...timers
|
|
1965
|
+
});
|
|
1966
|
+
emitPhaseCompleted(args.onPhase, now, cloneStartedAt, "clone", clone.ok, clone.ok ? "subject repository cloned" : "subject clone failed");
|
|
1967
|
+
if (!clone.ok) {
|
|
1968
|
+
throw new Error(`subject clone ${clone.timedOut ? "timed out" : `failed (exit ${clone.exitCode})`}: ${tailOf(args.scrub(clone.logTail))}`);
|
|
1969
|
+
}
|
|
1970
|
+
await refreshCommit();
|
|
1971
|
+
await runSubjectServePipeline(desktop, {
|
|
1972
|
+
serve: args.serve,
|
|
1973
|
+
...(args.state === undefined ? {} : { state: args.state }),
|
|
1974
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
1975
|
+
scrub: args.scrub,
|
|
1976
|
+
...(args.onStateStep === undefined ? {} : { onStateStep: args.onStateStep }),
|
|
1977
|
+
...(args.onPhase === undefined ? {} : { onPhase: args.onPhase }),
|
|
1978
|
+
onPhaseComplete: refreshCommit,
|
|
1979
|
+
...timers
|
|
1980
|
+
});
|
|
1981
|
+
return latestCommit;
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Provision a local-tree subject inside the sandbox: upload the once-per-run packed archive
|
|
1985
|
+
* (identical bytes across every fan-out lane) → extract it into SUBJECT_DIR → the
|
|
1986
|
+
* same shared serve pipeline provisionCloneSubject uses. Unlike the clone route there is no
|
|
1987
|
+
* in-sandbox git refresh: the archive excludes .git entirely (see source-archive.ts), so
|
|
1988
|
+
* subject identity is the host-side LocalTreeArchive captured at pack time, never anything
|
|
1989
|
+
* resolved in-sandbox.
|
|
1990
|
+
*/
|
|
1991
|
+
export async function provisionLocalTreeSubject(desktop, args) {
|
|
1992
|
+
const timers = {
|
|
1993
|
+
...(args.now === undefined ? {} : { now: args.now }),
|
|
1994
|
+
...(args.sleep === undefined ? {} : { sleep: args.sleep })
|
|
1995
|
+
};
|
|
1996
|
+
const now = args.now ?? Date.now;
|
|
1997
|
+
const uploadStartedAt = now();
|
|
1998
|
+
emitPhaseStarted(args.onPhase, now, "upload", "uploading packed local-tree archive");
|
|
1999
|
+
try {
|
|
2000
|
+
await desktop.files.write(LOCAL_TREE_REMOTE_ARCHIVE_PATH, args.archiveBuffer, {
|
|
2001
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
2002
|
+
useOctetStream: true
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
catch (error) {
|
|
2006
|
+
emitPhaseCompleted(args.onPhase, now, uploadStartedAt, "upload", false, "local-tree archive upload failed");
|
|
2007
|
+
throw new Error(`subject-upload failed: ${tailOf(args.scrub(toErrorMessage(error)))}`);
|
|
2008
|
+
}
|
|
2009
|
+
emitPhaseCompleted(args.onPhase, now, uploadStartedAt, "upload", true, "local-tree archive uploaded");
|
|
2010
|
+
const extractCommand = `rm -rf ${SUBJECT_DIR} && mkdir -p ${SUBJECT_DIR} && tar -xzf ${LOCAL_TREE_REMOTE_ARCHIVE_PATH} -C ${SUBJECT_DIR} && rm -f ${LOCAL_TREE_REMOTE_ARCHIVE_PATH}`;
|
|
2011
|
+
const extractStartedAt = now();
|
|
2012
|
+
emitPhaseStarted(args.onPhase, now, "extract", "extracting local-tree archive");
|
|
2013
|
+
const extract = await runDetachedStep(desktop, {
|
|
2014
|
+
name: "subject-extract",
|
|
2015
|
+
command: extractCommand,
|
|
2016
|
+
timeoutMs: CLONE_TIMEOUT_MS,
|
|
2017
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
2018
|
+
...timers
|
|
2019
|
+
});
|
|
2020
|
+
emitPhaseCompleted(args.onPhase, now, extractStartedAt, "extract", extract.ok, extract.ok ? "local-tree archive extracted" : "local-tree archive extraction failed");
|
|
2021
|
+
if (!extract.ok) {
|
|
2022
|
+
throw new Error(`subject extract ${extract.timedOut ? "timed out" : `failed (exit ${extract.exitCode})`}: ${tailOf(args.scrub(extract.logTail))}`);
|
|
2023
|
+
}
|
|
2024
|
+
await runSubjectServePipeline(desktop, {
|
|
2025
|
+
serve: args.serve,
|
|
2026
|
+
...(args.state === undefined ? {} : { state: args.state }),
|
|
2027
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
2028
|
+
scrub: args.scrub,
|
|
2029
|
+
...(args.onStateStep === undefined ? {} : { onStateStep: args.onStateStep }),
|
|
2030
|
+
...(args.onPhase === undefined ? {} : { onPhase: args.onPhase }),
|
|
2031
|
+
...timers
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Default local-tree packing implementation: createLocalTreeArchive(root, opts) on the host,
|
|
2036
|
+
* then a single read of the produced archive file into an ArrayBuffer for upload. The DI seam
|
|
2037
|
+
* (CuaActorLabHooks.packLocalTree) overrides this in deterministic tests so they never require
|
|
2038
|
+
* tar/git.
|
|
2039
|
+
*/
|
|
2040
|
+
export async function defaultPackLocalTree(args) {
|
|
2041
|
+
const archive = createLocalTreeArchive(args.root, {
|
|
2042
|
+
...(args.extraExclude === undefined ? {} : { extraExclude: args.extraExclude }),
|
|
2043
|
+
...(args.maxArchiveBytes === undefined ? {} : { maxArchiveBytes: args.maxArchiveBytes })
|
|
2044
|
+
});
|
|
2045
|
+
const bytes = await readFile(archive.archivePath);
|
|
2046
|
+
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
2047
|
+
// The archive was written to a fresh mkdtemp dir (no outputPath passed above); once the
|
|
2048
|
+
// bytes are buffered the on-disk copy is pure residue, and a packed working tree left in
|
|
2049
|
+
// the host tmpdir is itself a small leak surface. Best-effort removal.
|
|
2050
|
+
await rm(path.dirname(archive.archivePath), { recursive: true, force: true }).catch(() => undefined);
|
|
2051
|
+
return { archive, buffer };
|
|
2052
|
+
}
|
|
2053
|
+
/** sha256 hex of the exact command string, first 16 chars (the promptDigest convention). */
|
|
2054
|
+
export function commandDigestOf(command) {
|
|
2055
|
+
return digestText(command, 16);
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Resolve the bundle's state marker from the declaration and what actually ran.
|
|
2059
|
+
* Precedence: external declared → "unpinned" (seed records, if any, stay attached — a
|
|
2060
|
+
* migrated external DB is still unpinned overall); else seed declared → "seeded" only when
|
|
2061
|
+
* every declared step executed ok on a live run, otherwise "declared-not-run" (dry-run
|
|
2062
|
+
* contract bundles and failed live provisioning); no declaration → "undeclared".
|
|
2063
|
+
*/
|
|
2064
|
+
export function resolveSubjectState(args) {
|
|
2065
|
+
const declared = args.declared;
|
|
2066
|
+
if (!declared) {
|
|
2067
|
+
return { provenance: "undeclared" };
|
|
2068
|
+
}
|
|
2069
|
+
const declaredSeed = declared.seed ?? [];
|
|
2070
|
+
const external = declared.external ?? [];
|
|
2071
|
+
// Dry-run: nothing executes (no sandbox) — record the DECLARED recipe: name, phase, and
|
|
2072
|
+
// command digest only, with NO execution fields.
|
|
2073
|
+
const seed = args.dryRun
|
|
2074
|
+
? declaredSeed.map((step) => ({
|
|
2075
|
+
name: step.name,
|
|
2076
|
+
when: step.when ?? "before-start",
|
|
2077
|
+
commandDigest: commandDigestOf(step.command)
|
|
2078
|
+
}))
|
|
2079
|
+
: args.executed;
|
|
2080
|
+
const allRanOk = !args.dryRun
|
|
2081
|
+
&& declaredSeed.length > 0
|
|
2082
|
+
&& seed.length === declaredSeed.length
|
|
2083
|
+
&& seed.every((record) => record.ok === true);
|
|
2084
|
+
const provenance = external.length > 0
|
|
2085
|
+
? "unpinned"
|
|
2086
|
+
: declaredSeed.length === 0
|
|
2087
|
+
? "undeclared"
|
|
2088
|
+
: allRanOk
|
|
2089
|
+
? "seeded"
|
|
2090
|
+
: "declared-not-run";
|
|
2091
|
+
return {
|
|
2092
|
+
provenance,
|
|
2093
|
+
...(seed.length > 0 ? { seed } : {}),
|
|
2094
|
+
...(external.length > 0 ? { externalEnvNames: external } : {})
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
/** The human-readable state story appended to the provenance event (and review.md via it). */
|
|
2098
|
+
function describeSubjectState(state, dryRun) {
|
|
2099
|
+
switch (state.provenance) {
|
|
2100
|
+
case "seeded":
|
|
2101
|
+
return `seeded (${state.seed?.length ?? 0} step(s): ${(state.seed ?? []).map((record) => record.name).join(", ")})`;
|
|
2102
|
+
case "unpinned":
|
|
2103
|
+
return `UNPINNED (external: ${(state.externalEnvNames ?? []).join(", ")})`;
|
|
2104
|
+
case "declared-not-run":
|
|
2105
|
+
return `declared, not run (${dryRun ? "dry-run contract" : "provisioning did not complete"})`;
|
|
2106
|
+
case "undeclared":
|
|
2107
|
+
return "undeclared";
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
// The in-sandbox `tail -c` upstream is a fundamental log-tail limit we cannot redact past.
|
|
2111
|
+
function tailOf(log) {
|
|
2112
|
+
return redactedTail(log, ERROR_TAIL_CHARS);
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Project a computer-use session into a humanish.run-bundle.v1. The load-bearing line is
|
|
2116
|
+
* `stream.actor = session.trace` — the provider-neutral ActorTrace seam the Observer renders.
|
|
2117
|
+
* Exported for the bundle-builder tests.
|
|
2118
|
+
*/
|
|
2119
|
+
export function buildCuaBundle(args) {
|
|
2120
|
+
const publicAppUrl = publicSafeAppUrlLabel(args.appUrl);
|
|
2121
|
+
const status = args.inProgress === true
|
|
2122
|
+
? "running"
|
|
2123
|
+
: args.session
|
|
2124
|
+
? args.session.status
|
|
2125
|
+
: args.sessionError
|
|
2126
|
+
? "failed"
|
|
2127
|
+
: "contract_proof_only";
|
|
2128
|
+
const reason = args.inProgress === true
|
|
2129
|
+
? "Live computer-use session is running; stream auth URL is available only through the attached Observer server."
|
|
2130
|
+
: args.session?.reason
|
|
2131
|
+
?? args.sessionError
|
|
2132
|
+
?? "Contract bundle only: dry-run produced the evidence shape without launching a desktop or spending provider tokens.";
|
|
2133
|
+
const lastScreenshot = args.screenshots[args.screenshots.length - 1];
|
|
2134
|
+
// Honest labels (invariant 6: claims match mechanism): every screenshot label names the
|
|
2135
|
+
// run's ACTUAL mode. The session trace is the evidence-of-record; the capture policy covers
|
|
2136
|
+
// frames written before a mid-session failure produced a trace.
|
|
2137
|
+
const traceScreenshotMode = args.session?.trace.redaction.screenshots;
|
|
2138
|
+
const screenshotMode = traceScreenshotMode === "raw" || traceScreenshotMode === "blurred"
|
|
2139
|
+
? traceScreenshotMode
|
|
2140
|
+
: args.captureRedaction ?? "raw";
|
|
2141
|
+
const simulation = {
|
|
2142
|
+
id: "sim-001",
|
|
2143
|
+
index: 1,
|
|
2144
|
+
personaId: args.persona.id,
|
|
2145
|
+
scenarioId: `cua-${args.labId}`,
|
|
2146
|
+
status,
|
|
2147
|
+
streamKind: "browser",
|
|
2148
|
+
mode: "browser-sim",
|
|
2149
|
+
progress: args.inProgress === true ? 20 : 100,
|
|
2150
|
+
currentStep: reason,
|
|
2151
|
+
summary: args.session
|
|
2152
|
+
? `Computer-use actor (${args.actorId}) drove the subject app in a hosted desktop browser; ${args.session.completionReason}.`
|
|
2153
|
+
: args.inProgress === true
|
|
2154
|
+
? `Computer-use actor (${args.actorId}) is driving the subject app in a hosted desktop browser.`
|
|
2155
|
+
: args.sessionError
|
|
2156
|
+
? `Computer-use lab failed before a terminal session verdict: ${args.sessionError}`
|
|
2157
|
+
: `Contract lane for the computer-use actor (${args.actorId}) against ${publicAppUrl}.`,
|
|
2158
|
+
streamIds: ["stream-001"],
|
|
2159
|
+
startedAt: args.createdAt,
|
|
2160
|
+
updatedAt: args.createdAt
|
|
2161
|
+
};
|
|
2162
|
+
const stream = {
|
|
2163
|
+
id: "stream-001",
|
|
2164
|
+
simId: "sim-001",
|
|
2165
|
+
laneId: args.laneId ?? "lane-01",
|
|
2166
|
+
...(args.actorType === undefined ? {} : { actorType: args.actorType }),
|
|
2167
|
+
...(args.surface === undefined ? {} : { surface: args.surface }),
|
|
2168
|
+
...(args.caseGroup === undefined ? {} : { caseGroup: args.caseGroup }),
|
|
2169
|
+
kind: "browser",
|
|
2170
|
+
label: `CUA browser — ${args.labId}`,
|
|
2171
|
+
status,
|
|
2172
|
+
transport: "snapshot",
|
|
2173
|
+
updatedAt: args.createdAt,
|
|
2174
|
+
embed: lastScreenshot
|
|
2175
|
+
? { kind: "screenshot", url: lastScreenshot, title: `CUA desktop (${screenshotMode})` }
|
|
2176
|
+
: { kind: "placeholder", title: "CUA desktop" },
|
|
2177
|
+
viewport: {
|
|
2178
|
+
width: args.resolution[0],
|
|
2179
|
+
height: args.resolution[1],
|
|
2180
|
+
deviceScaleFactor: args.deviceScaleFactor ?? 1,
|
|
2181
|
+
...(args.isMobile === undefined ? {} : { isMobile: args.isMobile })
|
|
2182
|
+
},
|
|
2183
|
+
ui: {
|
|
2184
|
+
route: publicAppUrl,
|
|
2185
|
+
intent: "Watch the computer-use actor drive the subject app in a hosted desktop browser.",
|
|
2186
|
+
state: reason,
|
|
2187
|
+
...(args.session ? { actorStatus: args.session.status } : {}),
|
|
2188
|
+
...(lastScreenshot ? { screenshotUrl: lastScreenshot } : {})
|
|
2189
|
+
},
|
|
2190
|
+
// The seam this lab exists to fill: the provider-neutral actor evidence projection.
|
|
2191
|
+
...(args.session ? { actor: args.session.trace } : {}),
|
|
2192
|
+
artifacts: [
|
|
2193
|
+
{ label: "run bundle", path: "run.json", kind: "bundle" },
|
|
2194
|
+
{ label: "review", path: "review.md", kind: "review" },
|
|
2195
|
+
{ label: "events", path: "events.ndjson", kind: "events" },
|
|
2196
|
+
...(args.traceArtifactPath
|
|
2197
|
+
? [{ label: "actor trace", path: args.traceArtifactPath, kind: "trace" }]
|
|
2198
|
+
: []),
|
|
2199
|
+
...args.screenshots.map((screenshot, index) => ({
|
|
2200
|
+
label: `screenshot ${String(index + 1).padStart(2, "0")} (${screenshotMode})`,
|
|
2201
|
+
path: screenshot,
|
|
2202
|
+
kind: "screenshot"
|
|
2203
|
+
}))
|
|
2204
|
+
]
|
|
2205
|
+
};
|
|
2206
|
+
const events = [
|
|
2207
|
+
{
|
|
2208
|
+
id: "event-000-created",
|
|
2209
|
+
at: args.createdAt,
|
|
2210
|
+
level: "info",
|
|
2211
|
+
type: "cua-lab.run.created",
|
|
2212
|
+
message: `Created computer-use lab run for ${args.labId} (actor ${args.actorId}).`
|
|
2213
|
+
},
|
|
2214
|
+
args.subjectProvenance
|
|
2215
|
+
? {
|
|
2216
|
+
id: "event-001-subject",
|
|
2217
|
+
at: args.createdAt,
|
|
2218
|
+
level: "info",
|
|
2219
|
+
type: "cua-lab.subject.provenance",
|
|
2220
|
+
// HONEST WORDING: claim "cloned/packed and served" only when it actually happened.
|
|
2221
|
+
message: `${subjectProvenanceMessage(args.subjectProvenance, publicAppUrl, args.dryRun, args.session !== undefined)} (subject env names: ${args.subjectProvenance.envNames.length > 0 ? args.subjectProvenance.envNames.join(", ") : "none"}; values never persisted); state: ${describeSubjectState(args.subjectProvenance.state, args.dryRun)}.`,
|
|
2222
|
+
simId: "sim-001",
|
|
2223
|
+
streamId: "stream-001"
|
|
2224
|
+
}
|
|
2225
|
+
: {
|
|
2226
|
+
id: "event-001-subject",
|
|
2227
|
+
at: args.createdAt,
|
|
2228
|
+
level: "info",
|
|
2229
|
+
type: "cua-lab.subject.declared",
|
|
2230
|
+
// Invariant 5: declare what the subject WAS, including the ABSENCE of a pin. A
|
|
2231
|
+
// local-app / in-process subject is an already-running LOCAL dev server the caller
|
|
2232
|
+
// provisioned; it cannot be commit-pinned, so its provenance is honestly UNPINNED and
|
|
2233
|
+
// no E2B desktop was created. A plain app-url entry runs inside the desktop sandbox.
|
|
2234
|
+
message: args.entryKind === "local-app"
|
|
2235
|
+
? `Subject app declared at ${publicAppUrl} (already-running LOCAL dev server driven in-process; NO clone, NO E2B desktop). Provenance: caller-provisioned and UNPINNED — a running dev server cannot be commit-pinned.`
|
|
2236
|
+
: `Subject app declared at ${publicAppUrl} (loopback inside the desktop sandbox).`,
|
|
2237
|
+
simId: "sim-001",
|
|
2238
|
+
streamId: "stream-001"
|
|
2239
|
+
},
|
|
2240
|
+
args.session
|
|
2241
|
+
? {
|
|
2242
|
+
id: "event-002-session",
|
|
2243
|
+
at: args.createdAt,
|
|
2244
|
+
level: args.session.status === "passed" ? "info" : "warn",
|
|
2245
|
+
type: `cua-lab.session.${args.session.completionReason}`,
|
|
2246
|
+
message: `${args.session.status}: ${args.session.reason}`,
|
|
2247
|
+
simId: "sim-001",
|
|
2248
|
+
streamId: "stream-001"
|
|
2249
|
+
}
|
|
2250
|
+
: args.inProgress === true
|
|
2251
|
+
? {
|
|
2252
|
+
id: "event-002-running",
|
|
2253
|
+
at: args.createdAt,
|
|
2254
|
+
level: "info",
|
|
2255
|
+
type: "cua-lab.session.running",
|
|
2256
|
+
message: "Live computer-use session is running; terminal evidence has not been written yet.",
|
|
2257
|
+
simId: "sim-001",
|
|
2258
|
+
streamId: "stream-001"
|
|
2259
|
+
}
|
|
2260
|
+
: args.sessionError
|
|
2261
|
+
? {
|
|
2262
|
+
id: "event-002-session",
|
|
2263
|
+
at: args.createdAt,
|
|
2264
|
+
level: "error",
|
|
2265
|
+
type: "cua-lab.session.error",
|
|
2266
|
+
message: args.sessionError,
|
|
2267
|
+
simId: "sim-001",
|
|
2268
|
+
streamId: "stream-001"
|
|
2269
|
+
}
|
|
2270
|
+
: {
|
|
2271
|
+
id: "event-002-contract",
|
|
2272
|
+
at: args.createdAt,
|
|
2273
|
+
level: "info",
|
|
2274
|
+
type: "cua-lab.contract.ready",
|
|
2275
|
+
message: "Dry-run contract bundle ready; switch scenario.mode to live for a real desktop session.",
|
|
2276
|
+
simId: "sim-001",
|
|
2277
|
+
streamId: "stream-001"
|
|
2278
|
+
}
|
|
2279
|
+
];
|
|
2280
|
+
// Persisted phase trail (real boot timing, not just a coarse provenance sentence): one
|
|
2281
|
+
// RunEvent per COMPLETED phase boundary (started events never persist here; they carry no
|
|
2282
|
+
// durationMs). ok:false phases warn rather than error, since the failing phase's own thrown
|
|
2283
|
+
// error already becomes the terminal cua-lab.session.error event above.
|
|
2284
|
+
let phaseEventSeq = 3;
|
|
2285
|
+
for (const phase of args.phaseEvents ?? []) {
|
|
2286
|
+
events.push({
|
|
2287
|
+
id: `event-${String(phaseEventSeq++).padStart(3, "0")}-phase-${phaseEventIdSuffix(phase.type)}`,
|
|
2288
|
+
at: phase.at,
|
|
2289
|
+
level: phase.ok === false ? "warn" : "info",
|
|
2290
|
+
type: phase.type,
|
|
2291
|
+
message: phase.durationMs === undefined ? phase.message : `${phase.message} (${phase.durationMs}ms)`,
|
|
2292
|
+
simId: "sim-001",
|
|
2293
|
+
streamId: "stream-001"
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
const review = {
|
|
2297
|
+
schema: REVIEW_SCHEMA,
|
|
2298
|
+
verdict: args.inProgress === true
|
|
2299
|
+
? "contract_proof_only"
|
|
2300
|
+
: args.session
|
|
2301
|
+
? verdictForStatus(args.session.status)
|
|
2302
|
+
: args.sessionError
|
|
2303
|
+
? "fail"
|
|
2304
|
+
: "contract_proof_only",
|
|
2305
|
+
summary: reason,
|
|
2306
|
+
gaps: args.session || args.sessionError
|
|
2307
|
+
? []
|
|
2308
|
+
: args.inProgress === true
|
|
2309
|
+
? ["Live desktop session is still running."]
|
|
2310
|
+
: ["Live desktop session not yet run (dry-run contract only)."]
|
|
2311
|
+
};
|
|
2312
|
+
return {
|
|
2313
|
+
schema: RUN_BUNDLE_SCHEMA,
|
|
2314
|
+
runId: args.runId,
|
|
2315
|
+
mode: args.dryRun ? "dry-run" : "live",
|
|
2316
|
+
simCount: 1,
|
|
2317
|
+
createdAt: args.createdAt,
|
|
2318
|
+
cwd: PUBLIC_TARGET_CWD,
|
|
2319
|
+
artifactRoot: path.join(".humanish", "runs", args.runId),
|
|
2320
|
+
source: args.source,
|
|
2321
|
+
persona: {
|
|
2322
|
+
id: args.persona.id,
|
|
2323
|
+
name: `Computer-use operator (${args.persona.id})`,
|
|
2324
|
+
source: `lab:${args.labId}`,
|
|
2325
|
+
sourceDigest: args.persona.promptDigest
|
|
2326
|
+
},
|
|
2327
|
+
scenario: {
|
|
2328
|
+
id: `cua-${args.labId}`,
|
|
2329
|
+
title: args.labTitle ?? `Computer-use lab: ${args.labId}`,
|
|
2330
|
+
goal: args.mission,
|
|
2331
|
+
source: `lab:${args.labId}`,
|
|
2332
|
+
sourceDigest: args.persona.promptDigest
|
|
2333
|
+
},
|
|
2334
|
+
lifecycle: [
|
|
2335
|
+
{
|
|
2336
|
+
at: args.createdAt,
|
|
2337
|
+
event: "cua-lab.run.created",
|
|
2338
|
+
message: `Created computer-use lab run with one desktop browser lane (actor ${args.actorId}).`
|
|
2339
|
+
}
|
|
2340
|
+
],
|
|
2341
|
+
simulations: [simulation],
|
|
2342
|
+
streams: [stream],
|
|
2343
|
+
events,
|
|
2344
|
+
redaction: {
|
|
2345
|
+
status: "passed",
|
|
2346
|
+
notes: traceScreenshotMode === "raw"
|
|
2347
|
+
? "Typed text recorded as length only and reasoning/messages pass through text redaction. Screenshots are FULL-FIDELITY (raw), retained for local use — NOT redacted for publishing; set policies.redactScreenshots: true to blur a share-as-is bundle."
|
|
2348
|
+
: traceScreenshotMode === "blurred"
|
|
2349
|
+
? "Typed text recorded as length only and reasoning/messages pass through text redaction. Screenshots are blurred at capture (policies.redactScreenshots: true) for a share-as-is bundle."
|
|
2350
|
+
: args.screenshots.length > 0
|
|
2351
|
+
? `Session ended before a trace was recorded; ${args.screenshots.length} already-written frame(s) follow the capture policy (${screenshotMode}). Typed text is recorded as length only and reasoning/messages pass through text redaction.`
|
|
2352
|
+
: "No screenshots captured. Typed text is recorded as length only and reasoning/messages pass through text redaction whenever a session runs."
|
|
2353
|
+
},
|
|
2354
|
+
artifacts: {
|
|
2355
|
+
run: "run.json",
|
|
2356
|
+
reviewJson: "review.json",
|
|
2357
|
+
reviewMarkdown: "review.md",
|
|
2358
|
+
observerData: "observer/observer-data.json",
|
|
2359
|
+
events: "events.ndjson"
|
|
2360
|
+
},
|
|
2361
|
+
review,
|
|
2362
|
+
feedbackCandidates: [],
|
|
2363
|
+
// Custom desktop image provenance (omitted on the stock-template default → byte-stable).
|
|
2364
|
+
...(args.desktopTemplate === undefined ? {} : { desktopTemplate: args.desktopTemplate }),
|
|
2365
|
+
...(args.desktopBrowser === undefined ? {} : { desktopBrowser: args.desktopBrowser }),
|
|
2366
|
+
...(args.providerResources === undefined || args.providerResources.length === 0 ? {} : { providerResources: args.providerResources }),
|
|
2367
|
+
// Structured subject provenance (invariant 5): code pin + state story. Uniform and
|
|
2368
|
+
// honest on app-url bundles too — the caller minted the URL, its state is the caller's.
|
|
2369
|
+
// CuaSubjectProvenanceArg's two variants (clone, local-tree) are already RunSubjectProvenance-
|
|
2370
|
+
// shaped, so no reconstruction is needed beyond the app-url fallback.
|
|
2371
|
+
subject: args.subjectProvenance ?? { source: "app-url", state: { provenance: "undeclared" } }
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
/** Human-readable provenance line for the single-lane subject.provenance event (invariant 5):
|
|
2375
|
+
* claims "cloned/packed and served" ONLY when it actually happened. */
|
|
2376
|
+
function subjectProvenanceMessage(provenance, publicAppUrl, dryRun, hasSession) {
|
|
2377
|
+
if (provenance.source === "clone") {
|
|
2378
|
+
if (dryRun) {
|
|
2379
|
+
return `Subject declared: clone of ${provenance.repo}, to be served at ${publicAppUrl} in-sandbox (dry-run contract; nothing cloned)`;
|
|
2380
|
+
}
|
|
2381
|
+
if (provenance.commit) {
|
|
2382
|
+
return hasSession
|
|
2383
|
+
? `Subject cloned from ${provenance.repo}@${provenance.commit} and served at ${publicAppUrl} in-sandbox`
|
|
2384
|
+
: `Subject cloned from ${provenance.repo}@${provenance.commit}; serving at ${publicAppUrl} did not complete (see session error)`;
|
|
2385
|
+
}
|
|
2386
|
+
return `Subject clone attempted from ${provenance.repo}; commit unresolved (provisioning failed before resolution)`;
|
|
2387
|
+
}
|
|
2388
|
+
if (dryRun) {
|
|
2389
|
+
return `Subject declared: local working tree, to be packed and served at ${publicAppUrl} in-sandbox (dry-run contract; nothing packed)`;
|
|
2390
|
+
}
|
|
2391
|
+
if (provenance.archiveSha256) {
|
|
2392
|
+
const dirtyLabel = provenance.dirty === true ? ", dirty working tree" : provenance.dirty === false ? ", clean working tree" : "";
|
|
2393
|
+
return hasSession
|
|
2394
|
+
? `Subject packed (archiveSha256 ${provenance.archiveSha256}${dirtyLabel}) and served at ${publicAppUrl} in-sandbox`
|
|
2395
|
+
: `Subject packed (archiveSha256 ${provenance.archiveSha256}${dirtyLabel}); serving at ${publicAppUrl} did not complete (see session error)`;
|
|
2396
|
+
}
|
|
2397
|
+
return "Subject local-tree packing attempted; archive digest unresolved (provisioning failed before resolution)";
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* Project N>1 fan-out lanes into a humanish.run-bundle.v1 (the evidence schema is unchanged; this
|
|
2401
|
+
* is a new producer for the multi-stream shape). One sim + one stream per lane; per-lane
|
|
2402
|
+
* provenance/session events; a recorded `cua-lab.fanout.plan` event (and a `cua-lab.fanout.fail-fast`
|
|
2403
|
+
* event when a harness error skipped queued lanes). N-ary verify/Observer already handle multiple
|
|
2404
|
+
* streams. The N=1 path NEVER reaches here (buildCuaBundle owns it, byte-stable).
|
|
2405
|
+
*/
|
|
2406
|
+
export function buildCuaFanoutBundle(args) {
|
|
2407
|
+
const { specs, outcomes, config } = args;
|
|
2408
|
+
const simulations = [];
|
|
2409
|
+
const streams = [];
|
|
2410
|
+
const events = [];
|
|
2411
|
+
events.push({
|
|
2412
|
+
id: "event-000-created",
|
|
2413
|
+
at: args.createdAt,
|
|
2414
|
+
level: "info",
|
|
2415
|
+
type: "cua-lab.run.created",
|
|
2416
|
+
message: `Created computer-use fan-out run for ${config.id} (actor ${args.descriptor.id}, ${specs.length} lanes, per-lane worlds).`
|
|
2417
|
+
});
|
|
2418
|
+
events.push({
|
|
2419
|
+
id: "event-001-fanout-plan",
|
|
2420
|
+
at: args.createdAt,
|
|
2421
|
+
level: "info",
|
|
2422
|
+
type: "cua-lab.fanout.plan",
|
|
2423
|
+
message: `Fan-out plan: ${args.plan.laneCount} lane(s) (${args.plan.strategy}), concurrency ${args.plan.concurrency}, ${args.plan.waves} wave(s); per-lane session budget ${Math.round(args.plan.perLaneSessionBudgetMs / 1000)}s; worst-case ~${args.plan.worstCaseSandboxMinutes} sandbox-minutes${args.dryRun ? " (dry-run: $0)" : ""}. Lanes: ${args.plan.lanes.map(formatLanePlanEntry).join(", ")}.`
|
|
2424
|
+
});
|
|
2425
|
+
let eventSeq = 2;
|
|
2426
|
+
const nextEventId = (suffix) => `event-${String(eventSeq++).padStart(3, "0")}-${suffix}`;
|
|
2427
|
+
if (args.rerun) {
|
|
2428
|
+
events.push({
|
|
2429
|
+
id: nextEventId("fanout-rerun"),
|
|
2430
|
+
at: args.createdAt,
|
|
2431
|
+
level: "info",
|
|
2432
|
+
type: "cua-lab.fanout.rerun",
|
|
2433
|
+
message: `Rerun selected ${args.rerun.selectedLaneIds.length} lane(s) from ${args.rerun.sourceRunId}: ${args.rerun.previous.map((lane) => `${lane.laneId} was ${lane.status}${lane.completionReason ? `/${lane.completionReason}` : ""}`).join(", ")}. This is a new linked run; the source run verdict is unchanged.`
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
specs.forEach((spec, index) => {
|
|
2437
|
+
const outcome = outcomes?.[index];
|
|
2438
|
+
const laneAppUrl = spec.targetUrl ?? args.appUrl;
|
|
2439
|
+
const publicLaneAppUrl = publicSafeAppUrlLabel(laneAppUrl);
|
|
2440
|
+
const subject = args.laneSubjects[index];
|
|
2441
|
+
const session = outcome?.session;
|
|
2442
|
+
const screenshots = outcome?.screenshots ?? [];
|
|
2443
|
+
const lastScreenshot = screenshots[screenshots.length - 1];
|
|
2444
|
+
const status = args.inProgress === true && outcome === undefined
|
|
2445
|
+
? "running"
|
|
2446
|
+
: outcome?.skippedReason !== undefined
|
|
2447
|
+
? "blocked"
|
|
2448
|
+
: session
|
|
2449
|
+
? session.status
|
|
2450
|
+
: outcome?.sessionError
|
|
2451
|
+
? "failed"
|
|
2452
|
+
: "contract_proof_only";
|
|
2453
|
+
const reason = args.inProgress === true && outcome === undefined
|
|
2454
|
+
? "Live computer-use lane is running; stream auth URL is available only through the attached Observer server."
|
|
2455
|
+
: outcome?.skippedReason
|
|
2456
|
+
?? session?.reason
|
|
2457
|
+
?? outcome?.sessionError
|
|
2458
|
+
?? "Contract bundle only: dry-run produced the evidence shape without launching a desktop or spending provider tokens.";
|
|
2459
|
+
const traceScreenshotMode = session?.trace.redaction.screenshots;
|
|
2460
|
+
const screenshotMode = traceScreenshotMode === "raw" || traceScreenshotMode === "blurred"
|
|
2461
|
+
? traceScreenshotMode
|
|
2462
|
+
: config.policies?.redactScreenshots === true ? "blurred" : "raw";
|
|
2463
|
+
simulations.push({
|
|
2464
|
+
id: spec.simId,
|
|
2465
|
+
index: index + 1,
|
|
2466
|
+
personaId: spec.persona.id,
|
|
2467
|
+
scenarioId: `cua-${config.id}`,
|
|
2468
|
+
status,
|
|
2469
|
+
streamKind: "browser",
|
|
2470
|
+
mode: "browser-sim",
|
|
2471
|
+
progress: args.inProgress === true && outcome === undefined ? 20 : 100,
|
|
2472
|
+
currentStep: reason,
|
|
2473
|
+
summary: session
|
|
2474
|
+
? `Lane ${spec.laneId} (${spec.persona.id}/${spec.deviceName}): computer-use actor (${args.descriptor.id}) drove the subject app; ${session.completionReason}.`
|
|
2475
|
+
: args.inProgress === true && outcome === undefined
|
|
2476
|
+
? `Lane ${spec.laneId} (${spec.persona.id}/${spec.deviceName}): computer-use actor (${args.descriptor.id}) is driving the subject app.`
|
|
2477
|
+
: outcome?.skippedReason !== undefined
|
|
2478
|
+
? `Lane ${spec.laneId} ${outcome.skippedReason}.`
|
|
2479
|
+
: outcome?.sessionError
|
|
2480
|
+
? `Lane ${spec.laneId} failed before a terminal session verdict: ${outcome.sessionError}`
|
|
2481
|
+
: `Contract lane ${spec.laneId} (${spec.persona.id}/${spec.deviceName}) for ${args.descriptor.id} against ${publicLaneAppUrl}.`,
|
|
2482
|
+
streamIds: [spec.streamId],
|
|
2483
|
+
startedAt: args.createdAt,
|
|
2484
|
+
updatedAt: args.createdAt
|
|
2485
|
+
});
|
|
2486
|
+
streams.push({
|
|
2487
|
+
id: spec.streamId,
|
|
2488
|
+
simId: spec.simId,
|
|
2489
|
+
laneId: spec.laneId,
|
|
2490
|
+
...(spec.actorType === undefined ? {} : { actorType: spec.actorType }),
|
|
2491
|
+
...(spec.surface === undefined ? {} : { surface: spec.surface }),
|
|
2492
|
+
...(spec.caseGroup === undefined ? {} : { caseGroup: spec.caseGroup }),
|
|
2493
|
+
kind: "browser",
|
|
2494
|
+
label: `CUA lane ${spec.laneId} — ${config.id}`,
|
|
2495
|
+
status,
|
|
2496
|
+
transport: "snapshot",
|
|
2497
|
+
updatedAt: args.createdAt,
|
|
2498
|
+
embed: lastScreenshot
|
|
2499
|
+
? { kind: "screenshot", url: lastScreenshot, title: `CUA desktop ${spec.laneId} (${screenshotMode})` }
|
|
2500
|
+
: { kind: "placeholder", title: `CUA desktop ${spec.laneId}` },
|
|
2501
|
+
viewport: {
|
|
2502
|
+
width: spec.resolution[0],
|
|
2503
|
+
height: spec.resolution[1],
|
|
2504
|
+
deviceScaleFactor: spec.devicePreset.deviceScaleFactor,
|
|
2505
|
+
isMobile: spec.devicePreset.isMobile
|
|
2506
|
+
},
|
|
2507
|
+
ui: {
|
|
2508
|
+
route: publicLaneAppUrl,
|
|
2509
|
+
intent: `Watch lane ${spec.laneId} (${spec.persona.id}/${spec.deviceName}) drive the subject app in its own hosted desktop.`,
|
|
2510
|
+
state: reason,
|
|
2511
|
+
...(session ? { actorStatus: session.status } : {}),
|
|
2512
|
+
...(lastScreenshot ? { screenshotUrl: lastScreenshot } : {})
|
|
2513
|
+
},
|
|
2514
|
+
...(session ? { actor: session.trace } : {}),
|
|
2515
|
+
artifacts: [
|
|
2516
|
+
{ label: "run bundle", path: "run.json", kind: "bundle" },
|
|
2517
|
+
{ label: "review", path: "review.md", kind: "review" },
|
|
2518
|
+
{ label: "events", path: "events.ndjson", kind: "events" },
|
|
2519
|
+
...(session
|
|
2520
|
+
? [{ label: `lane ${spec.laneId} actor trace`, path: spec.traceArtifactPath, kind: "trace" }]
|
|
2521
|
+
: []),
|
|
2522
|
+
...screenshots.map((screenshot, screenshotIndex) => ({
|
|
2523
|
+
label: `lane ${spec.laneId} screenshot ${String(screenshotIndex + 1).padStart(2, "0")} (${screenshotMode})`,
|
|
2524
|
+
path: screenshot,
|
|
2525
|
+
kind: "screenshot"
|
|
2526
|
+
}))
|
|
2527
|
+
]
|
|
2528
|
+
});
|
|
2529
|
+
// Per-lane subject provenance (invariant 5).
|
|
2530
|
+
if (args.cloneRoute && args.publicRepo) {
|
|
2531
|
+
events.push({
|
|
2532
|
+
id: nextEventId(`subject-${spec.laneId}`),
|
|
2533
|
+
at: args.createdAt,
|
|
2534
|
+
level: "info",
|
|
2535
|
+
type: "cua-lab.subject.provenance",
|
|
2536
|
+
message: `Lane ${spec.laneId}: ${args.dryRun
|
|
2537
|
+
? `subject declared — clone of ${args.publicRepo}, served at ${publicLaneAppUrl} in-sandbox (dry-run contract; nothing cloned)`
|
|
2538
|
+
: subject.commit
|
|
2539
|
+
? session
|
|
2540
|
+
? `subject cloned from ${args.publicRepo}@${subject.commit} and served at ${publicLaneAppUrl} in-sandbox`
|
|
2541
|
+
: `subject cloned from ${args.publicRepo}@${subject.commit}; serving did not complete (see session error)`
|
|
2542
|
+
: `subject clone attempted from ${args.publicRepo}; commit unresolved`} (subject env names: ${args.subjectEnvNames.length > 0 ? args.subjectEnvNames.join(", ") : "none"}; values never persisted); state: ${describeSubjectState(subject.state, args.dryRun)}.`,
|
|
2543
|
+
simId: spec.simId,
|
|
2544
|
+
streamId: spec.streamId
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
else if (subject.source === "local-tree") {
|
|
2548
|
+
events.push({
|
|
2549
|
+
id: nextEventId(`subject-${spec.laneId}`),
|
|
2550
|
+
at: args.createdAt,
|
|
2551
|
+
level: "info",
|
|
2552
|
+
type: "cua-lab.subject.provenance",
|
|
2553
|
+
message: `Lane ${spec.laneId}: ${args.dryRun
|
|
2554
|
+
? `subject declared: local working tree, to be packed and served at ${publicLaneAppUrl} in-sandbox (dry-run contract; nothing packed)`
|
|
2555
|
+
: subject.archiveSha256
|
|
2556
|
+
? session
|
|
2557
|
+
? `subject packed (archiveSha256 ${subject.archiveSha256}${subject.dirty === true ? ", dirty working tree" : subject.dirty === false ? ", clean working tree" : ""}) and served at ${publicLaneAppUrl} in-sandbox`
|
|
2558
|
+
: `subject packed (archiveSha256 ${subject.archiveSha256}); serving did not complete (see session error)`
|
|
2559
|
+
: "subject local-tree packing attempted; archive digest unresolved"} (subject env names: ${args.subjectEnvNames.length > 0 ? args.subjectEnvNames.join(", ") : "none"}; values never persisted); state: ${describeSubjectState(subject.state, args.dryRun)}.`,
|
|
2560
|
+
simId: spec.simId,
|
|
2561
|
+
streamId: spec.streamId
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
else {
|
|
2565
|
+
events.push({
|
|
2566
|
+
id: nextEventId(`subject-${spec.laneId}`),
|
|
2567
|
+
at: args.createdAt,
|
|
2568
|
+
level: "info",
|
|
2569
|
+
type: "cua-lab.subject.declared",
|
|
2570
|
+
message: `Lane ${spec.laneId}: subject app declared at ${publicLaneAppUrl} (loopback inside the lane's own desktop sandbox).`,
|
|
2571
|
+
simId: spec.simId,
|
|
2572
|
+
streamId: spec.streamId
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
// Per-lane session event.
|
|
2576
|
+
if (session) {
|
|
2577
|
+
events.push({
|
|
2578
|
+
id: nextEventId(`session-${spec.laneId}`),
|
|
2579
|
+
at: args.createdAt,
|
|
2580
|
+
level: session.status === "passed" ? "info" : "warn",
|
|
2581
|
+
type: `cua-lab.session.${session.completionReason}`,
|
|
2582
|
+
message: `Lane ${spec.laneId}: ${session.status} — ${session.reason}`,
|
|
2583
|
+
simId: spec.simId,
|
|
2584
|
+
streamId: spec.streamId
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
else if (args.inProgress === true && outcome === undefined) {
|
|
2588
|
+
events.push({
|
|
2589
|
+
id: nextEventId(`running-${spec.laneId}`),
|
|
2590
|
+
at: args.createdAt,
|
|
2591
|
+
level: "info",
|
|
2592
|
+
type: "cua-lab.session.running",
|
|
2593
|
+
message: `Lane ${spec.laneId}: live computer-use session is running; terminal evidence has not been written yet.`,
|
|
2594
|
+
simId: spec.simId,
|
|
2595
|
+
streamId: spec.streamId
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
else if (outcome?.skippedReason !== undefined) {
|
|
2599
|
+
events.push({
|
|
2600
|
+
id: nextEventId(`blocked-${spec.laneId}`),
|
|
2601
|
+
at: args.createdAt,
|
|
2602
|
+
level: "warn",
|
|
2603
|
+
type: "cua-lab.session.blocked",
|
|
2604
|
+
message: `Lane ${spec.laneId} ${outcome.skippedReason}.`,
|
|
2605
|
+
simId: spec.simId,
|
|
2606
|
+
streamId: spec.streamId
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
else if (outcome?.sessionError) {
|
|
2610
|
+
events.push({
|
|
2611
|
+
id: nextEventId(`session-error-${spec.laneId}`),
|
|
2612
|
+
at: args.createdAt,
|
|
2613
|
+
level: "error",
|
|
2614
|
+
type: "cua-lab.session.error",
|
|
2615
|
+
message: `Lane ${spec.laneId}: ${outcome.sessionError}`,
|
|
2616
|
+
simId: spec.simId,
|
|
2617
|
+
streamId: spec.streamId
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
else {
|
|
2621
|
+
events.push({
|
|
2622
|
+
id: nextEventId(`contract-${spec.laneId}`),
|
|
2623
|
+
at: args.createdAt,
|
|
2624
|
+
level: "info",
|
|
2625
|
+
type: "cua-lab.contract.ready",
|
|
2626
|
+
message: `Lane ${spec.laneId}: dry-run contract lane ready; switch scenario.mode to live for a real desktop session.`,
|
|
2627
|
+
simId: spec.simId,
|
|
2628
|
+
streamId: spec.streamId
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
// Persisted per-lane phase trail (real boot timing): one RunEvent per COMPLETED phase
|
|
2632
|
+
// boundary this lane recorded (started events never persist here; they carry no durationMs).
|
|
2633
|
+
for (const phase of outcome?.phaseRecords ?? []) {
|
|
2634
|
+
events.push({
|
|
2635
|
+
id: nextEventId(`phase-${spec.laneId}-${phaseEventIdSuffix(phase.type)}`),
|
|
2636
|
+
at: phase.at,
|
|
2637
|
+
level: phase.ok === false ? "warn" : "info",
|
|
2638
|
+
type: phase.type,
|
|
2639
|
+
message: phase.durationMs === undefined
|
|
2640
|
+
? `Lane ${spec.laneId}: ${phase.message}`
|
|
2641
|
+
: `Lane ${spec.laneId}: ${phase.message} (${phase.durationMs}ms)`,
|
|
2642
|
+
simId: spec.simId,
|
|
2643
|
+
streamId: spec.streamId
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
});
|
|
2647
|
+
if (args.failFastReason) {
|
|
2648
|
+
events.push({
|
|
2649
|
+
id: nextEventId("fanout-fail-fast"),
|
|
2650
|
+
at: args.createdAt,
|
|
2651
|
+
level: "warn",
|
|
2652
|
+
type: "cua-lab.fanout.fail-fast",
|
|
2653
|
+
message: `Fan-out fail-fast: ${args.failFastReason}. In-flight lanes finished; queued lanes were skipped (blocked) — completed evidence is retained.`
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
2656
|
+
// Worst-of review verdict across lanes; live fan-out must prove every lane.
|
|
2657
|
+
const verdict = args.inProgress === true
|
|
2658
|
+
? "contract_proof_only"
|
|
2659
|
+
: fanoutReviewVerdict({
|
|
2660
|
+
dryRun: args.dryRun,
|
|
2661
|
+
expectedLaneCount: specs.length,
|
|
2662
|
+
outcomes
|
|
2663
|
+
});
|
|
2664
|
+
const passedLanes = (outcomes ?? []).filter((outcome) => outcome.skippedReason === undefined
|
|
2665
|
+
&& outcome.session !== undefined
|
|
2666
|
+
&& outcome.session.status === "passed"
|
|
2667
|
+
&& outcome.session.completionReason !== "harness_error"
|
|
2668
|
+
&& outcome.sessionError === undefined
|
|
2669
|
+
&& !outcome.noEngagement
|
|
2670
|
+
&& !outcome.selfReportedBlocker).length;
|
|
2671
|
+
const review = {
|
|
2672
|
+
schema: REVIEW_SCHEMA,
|
|
2673
|
+
verdict,
|
|
2674
|
+
summary: args.inProgress === true
|
|
2675
|
+
? `Live computer-use fan-out is running (${specs.length} per-lane worlds); terminal lane evidence has not been written yet.`
|
|
2676
|
+
: args.dryRun
|
|
2677
|
+
? `${args.rerun ? `Rerun contract from ${args.rerun.sourceRunId}: ` : ""}Dry-run fan-out contract: ${specs.length} per-lane-world lanes composed for ${args.descriptor.id} against ${args.appUrl}; no desktops launched, $0 spend.`
|
|
2678
|
+
: `${args.rerun ? `Rerun from ${args.rerun.sourceRunId}: ` : ""}Computer-use fan-out (${specs.length} per-lane worlds): ${passedLanes}/${specs.length} lane(s) reached a terminal, engaged verdict.`,
|
|
2679
|
+
gaps: args.inProgress === true
|
|
2680
|
+
? ["Live fan-out session is still running."]
|
|
2681
|
+
: args.dryRun
|
|
2682
|
+
? ["Live fan-out session not yet run (dry-run contract only)."]
|
|
2683
|
+
: specs
|
|
2684
|
+
.map((spec, index) => ({ spec, outcome: outcomes?.[index] }))
|
|
2685
|
+
.filter(({ outcome }) => outcome === undefined
|
|
2686
|
+
|| outcome.skippedReason !== undefined
|
|
2687
|
+
|| outcome.sessionError !== undefined
|
|
2688
|
+
|| outcome.noEngagement
|
|
2689
|
+
|| outcome.selfReportedBlocker
|
|
2690
|
+
|| outcome.session === undefined
|
|
2691
|
+
|| outcome.session.status !== "passed")
|
|
2692
|
+
.map(({ spec, outcome }) => `${spec.laneId}: ${outcome?.skippedReason ?? outcome?.sessionError ?? outcome?.session?.reason ?? "did not pass"}`)
|
|
2693
|
+
};
|
|
2694
|
+
const anyRaw = (outcomes ?? []).some((outcome) => outcome.session?.trace.redaction.screenshots === "raw");
|
|
2695
|
+
const ranLive = (outcomes ?? []).some((outcome) => outcome.session !== undefined || outcome.sessionError !== undefined);
|
|
2696
|
+
const configuredBrowser = config.execution?.desktop?.browser;
|
|
2697
|
+
const resolvedBrowsers = (outcomes ?? [])
|
|
2698
|
+
.map((outcome) => outcome.desktopBrowser?.resolved)
|
|
2699
|
+
.filter((value) => value !== undefined);
|
|
2700
|
+
const unanimousResolvedBrowser = resolvedBrowsers.length > 0 && new Set(resolvedBrowsers).size === 1
|
|
2701
|
+
? resolvedBrowsers[0]
|
|
2702
|
+
: undefined;
|
|
2703
|
+
const providerResources = (outcomes ?? []).flatMap((outcome) => providerResourcesForOutcome({
|
|
2704
|
+
outcome,
|
|
2705
|
+
createdAt: args.createdAt,
|
|
2706
|
+
simId: outcome.spec.simId,
|
|
2707
|
+
streamId: outcome.spec.streamId,
|
|
2708
|
+
laneId: outcome.spec.laneId
|
|
2709
|
+
}));
|
|
2710
|
+
return {
|
|
2711
|
+
schema: RUN_BUNDLE_SCHEMA,
|
|
2712
|
+
runId: args.runId,
|
|
2713
|
+
mode: args.dryRun ? "dry-run" : "live",
|
|
2714
|
+
simCount: specs.length,
|
|
2715
|
+
createdAt: args.createdAt,
|
|
2716
|
+
cwd: PUBLIC_TARGET_CWD,
|
|
2717
|
+
artifactRoot: path.join(".humanish", "runs", args.runId),
|
|
2718
|
+
source: args.source,
|
|
2719
|
+
persona: {
|
|
2720
|
+
id: specs[0].persona.id,
|
|
2721
|
+
name: `Computer-use fan-out (${specs.length} lanes)`,
|
|
2722
|
+
source: `lab:${config.id}`,
|
|
2723
|
+
sourceDigest: specs[0].persona.promptDigest
|
|
2724
|
+
},
|
|
2725
|
+
scenario: {
|
|
2726
|
+
id: `cua-${config.id}`,
|
|
2727
|
+
title: config.title ?? `Computer-use fan-out: ${config.id}`,
|
|
2728
|
+
goal: specs[0].instructions,
|
|
2729
|
+
source: `lab:${config.id}`,
|
|
2730
|
+
sourceDigest: specs[0].persona.promptDigest
|
|
2731
|
+
},
|
|
2732
|
+
lifecycle: [
|
|
2733
|
+
{
|
|
2734
|
+
at: args.createdAt,
|
|
2735
|
+
event: "cua-lab.run.created",
|
|
2736
|
+
message: `Created computer-use fan-out run with ${specs.length} per-lane desktop browser lanes (actor ${args.descriptor.id}).`
|
|
2737
|
+
}
|
|
2738
|
+
],
|
|
2739
|
+
simulations,
|
|
2740
|
+
streams,
|
|
2741
|
+
events,
|
|
2742
|
+
...(args.rerun === undefined ? {} : { rerun: args.rerun }),
|
|
2743
|
+
redaction: {
|
|
2744
|
+
status: "passed",
|
|
2745
|
+
notes: ranLive
|
|
2746
|
+
? anyRaw
|
|
2747
|
+
? "Typed text recorded as length only and reasoning/messages pass through text redaction. Some lanes captured FULL-FIDELITY (raw) screenshots, retained for local use — NOT redacted for publishing; set policies.redactScreenshots: true to blur a share-as-is bundle."
|
|
2748
|
+
: "Typed text recorded as length only and reasoning/messages pass through text redaction. Screenshots are blurred at capture (policies.redactScreenshots: true) for a share-as-is bundle."
|
|
2749
|
+
: "Dry-run fan-out contract bundle: no desktops launched and no screenshots captured. Typed text is recorded as length only and reasoning/messages pass through text redaction whenever a session runs."
|
|
2750
|
+
},
|
|
2751
|
+
artifacts: {
|
|
2752
|
+
run: "run.json",
|
|
2753
|
+
reviewJson: "review.json",
|
|
2754
|
+
reviewMarkdown: "review.md",
|
|
2755
|
+
observerData: "observer/observer-data.json",
|
|
2756
|
+
events: "events.ndjson"
|
|
2757
|
+
},
|
|
2758
|
+
review,
|
|
2759
|
+
feedbackCandidates: [],
|
|
2760
|
+
// Custom desktop image provenance (every lane launched on it); omitted on the stock default.
|
|
2761
|
+
...(config.execution?.desktop?.template === undefined ? {} : { desktopTemplate: config.execution.desktop.template }),
|
|
2762
|
+
...(configuredBrowser === undefined
|
|
2763
|
+
? {}
|
|
2764
|
+
: { desktopBrowser: { requested: configuredBrowser, ...(unanimousResolvedBrowser === undefined ? {} : { resolved: unanimousResolvedBrowser }) } }),
|
|
2765
|
+
...(providerResources.length === 0 ? {} : { providerResources }),
|
|
2766
|
+
subject: args.aggregateSubject
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
function providerResourcesForOutcome(args) {
|
|
2770
|
+
if (args.outcome?.sandboxId === undefined) {
|
|
2771
|
+
return [];
|
|
2772
|
+
}
|
|
2773
|
+
return [{
|
|
2774
|
+
schema: "humanish.provider-resource.v1",
|
|
2775
|
+
provider: "e2b-desktop",
|
|
2776
|
+
kind: "sandbox",
|
|
2777
|
+
id: args.outcome.sandboxId,
|
|
2778
|
+
owner: "humanish",
|
|
2779
|
+
status: args.outcome.killed ? "killed" : "running",
|
|
2780
|
+
simId: args.simId,
|
|
2781
|
+
streamId: args.streamId,
|
|
2782
|
+
laneId: args.laneId,
|
|
2783
|
+
createdAt: args.createdAt,
|
|
2784
|
+
cleanup: {
|
|
2785
|
+
killed: args.outcome.killed,
|
|
2786
|
+
reason: args.outcome.killed
|
|
2787
|
+
? "killed during normal lane teardown"
|
|
2788
|
+
: "not killed during normal lane teardown; cleanup may reclaim by exact recorded id"
|
|
2789
|
+
}
|
|
2790
|
+
}];
|
|
2791
|
+
}
|
|
2792
|
+
function verdictForStatus(status) {
|
|
2793
|
+
switch (status) {
|
|
2794
|
+
case "passed":
|
|
2795
|
+
return "pass";
|
|
2796
|
+
case "failed":
|
|
2797
|
+
return "fail";
|
|
2798
|
+
case "blocked":
|
|
2799
|
+
return "blocked";
|
|
2800
|
+
case "timed_out":
|
|
2801
|
+
return "timed_out";
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
function renderCuaReviewMarkdown(bundle) {
|
|
2805
|
+
const trace = bundle.streams[0]?.actor;
|
|
2806
|
+
const provenance = bundle.events.find((event) => event.type === "cua-lab.subject.provenance");
|
|
2807
|
+
return [
|
|
2808
|
+
`# ${bundle.scenario.title}`,
|
|
2809
|
+
"",
|
|
2810
|
+
`- run: ${bundle.runId}`,
|
|
2811
|
+
`- mode: ${bundle.mode}`,
|
|
2812
|
+
`- verdict: ${bundle.review.verdict}`,
|
|
2813
|
+
`- summary: ${bundle.review.summary}`,
|
|
2814
|
+
...(provenance ? [`- subject: ${provenance.message}`] : []),
|
|
2815
|
+
...(trace
|
|
2816
|
+
? [
|
|
2817
|
+
`- actor: ${trace.provider} (${trace.lane}/${trace.protocol})`,
|
|
2818
|
+
// Honest count: name the trace's actual screenshot mode ("raw" | "blurred"); say
|
|
2819
|
+
// nothing when no frames exist ("n/a") rather than claim a redaction that never ran.
|
|
2820
|
+
`- evidence: ${trace.items.length} trace item(s), ${trace.counts.screenshots ?? 0} ${trace.redaction.screenshots === "raw" || trace.redaction.screenshots === "blurred"
|
|
2821
|
+
? `${trace.redaction.screenshots} screenshot(s)`
|
|
2822
|
+
: "screenshot(s)"}`
|
|
2823
|
+
]
|
|
2824
|
+
: []),
|
|
2825
|
+
...(bundle.review.gaps.length > 0 ? ["", "## Gaps", ...bundle.review.gaps.map((gap) => `- ${gap}`)] : []),
|
|
2826
|
+
""
|
|
2827
|
+
].join("\n");
|
|
2828
|
+
}
|
|
2829
|
+
function makeCuaRunId() {
|
|
2830
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2831
|
+
return `cua-${stamp}-${randomBytes(4).toString("hex")}`;
|
|
2832
|
+
}
|
|
2833
|
+
function publicSafeAppUrlLabel(url) {
|
|
2834
|
+
return containsSensitive(url) ? `[target-url:${digestUrl(url)}]` : url;
|
|
2835
|
+
}
|
|
2836
|
+
function digestUrl(url) {
|
|
2837
|
+
return digestText(url, 16);
|
|
2838
|
+
}
|
|
2839
|
+
function readPositiveInt(value, fallback) {
|
|
2840
|
+
if (value === undefined)
|
|
2841
|
+
return fallback;
|
|
2842
|
+
const parsed = Number.parseInt(value, 10);
|
|
2843
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
2844
|
+
}
|
|
2845
|
+
//# sourceMappingURL=cua-actor-lab.js.map
|