@reticlehq/server 3.8.1 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -104
- package/NOTICE +5 -0
- package/README.md +1 -1
- package/bin/launch.js +13 -0
- package/bin/package.json +3 -0
- package/bin/reticle.js +66 -8
- package/dist/chromium-launch-options.d.ts +25 -0
- package/dist/chromium-launch-options.js +21 -1
- package/dist/command/cli/adhoc-verdict.js +34 -7
- package/dist/command/cli/answers/gate-exit.d.ts +6 -2
- package/dist/command/cli/answers/gate-exit.js +12 -2
- package/dist/command/cli/ci-hint.d.ts +44 -0
- package/dist/command/cli/ci-hint.js +72 -0
- package/dist/command/cli/cli-doctor.d.ts +6 -1
- package/dist/command/cli/cli-doctor.js +45 -4
- package/dist/command/cli/cli-flow-commands.d.ts +7 -1
- package/dist/command/cli/cli-flow-commands.js +39 -17
- package/dist/command/cli/cli-kill.d.ts +21 -0
- package/dist/command/cli/cli-kill.js +46 -3
- package/dist/command/cli/cli-parse.d.ts +5 -2
- package/dist/command/cli/cli-parse.js +10 -5
- package/dist/command/cli/cli-usage.js +3 -2
- package/dist/command/cli/cli-verify.d.ts +6 -1
- package/dist/command/cli/cli-verify.js +32 -13
- package/dist/command/cli/doctor/browser/chromium-hint.d.ts +6 -1
- package/dist/command/cli/doctor/browser/chromium-hint.js +11 -5
- package/dist/command/cli/doctor/doctor-fix.d.ts +44 -0
- package/dist/command/cli/doctor/doctor-fix.js +89 -0
- package/dist/command/cli/doctor/doctor-launcher-line.d.ts +23 -0
- package/dist/command/cli/doctor/doctor-launcher-line.js +60 -0
- package/dist/command/cli/doctor/doctor-rows.d.ts +4 -0
- package/dist/command/cli/doctor/doctor-rows.js +4 -0
- package/dist/command/cli/doctor-repairs.d.ts +64 -0
- package/dist/command/cli/doctor-repairs.js +214 -0
- package/dist/command/cli/drive/drive-command.js +3 -7
- package/dist/command/cli/first-use-checks.d.ts +41 -0
- package/dist/command/cli/first-use-checks.js +127 -0
- package/dist/command/cli/first-verdict-node.d.ts +3 -0
- package/dist/command/cli/first-verdict-node.js +189 -0
- package/dist/command/cli/first-verdict.d.ts +126 -0
- package/dist/command/cli/first-verdict.js +267 -0
- package/dist/command/cli/launch/sdk-version-match.d.ts +3 -2
- package/dist/command/cli/launch/sdk-version-match.js +3 -2
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +6 -5
- package/dist/command/cli/mcp-command.js +13 -4
- package/dist/command/cli/ports/port-holder.d.ts +2 -0
- package/dist/command/cli/ports/port-holder.js +11 -3
- package/dist/command/cli/setup-mcp-cli.d.ts +27 -1
- package/dist/command/cli/setup-mcp-cli.js +98 -23
- package/dist/command/cli/status/status-lines.d.ts +1 -0
- package/dist/command/cli/status/status-lines.js +50 -17
- package/dist/command/cli/status-fields.js +17 -3
- package/dist/command/cli/try-command.d.ts +7 -0
- package/dist/command/cli/try-command.js +11 -3
- package/dist/command/cli.js +70 -33
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +3 -1
- package/dist/command/daemon/binding/daemon-status-probe.js +4 -2
- package/dist/command/daemon/daemon-argv.d.ts +9 -0
- package/dist/command/daemon/daemon-argv.js +11 -0
- package/dist/command/daemon/daemon.d.ts +10 -3
- package/dist/command/daemon/daemon.js +17 -6
- package/dist/command/daemon/lifetime/daemon-exit.d.ts +24 -0
- package/dist/command/daemon/lifetime/daemon-exit.js +39 -0
- package/dist/command/daemon/lifetime/death-cause.d.ts +15 -0
- package/dist/command/daemon/lifetime/death-cause.js +130 -0
- package/dist/command/daemon/lifetime/heartbeat.d.ts +7 -15
- package/dist/command/daemon/lifetime/heartbeat.js +4 -13
- package/dist/command/daemon/port-state.d.ts +14 -0
- package/dist/command/daemon/port-state.js +31 -0
- package/dist/command/setup/agent-configs.d.ts +2 -0
- package/dist/command/setup/agent-configs.js +42 -15
- package/dist/command/setup/agent-next-steps.d.ts +77 -0
- package/dist/command/setup/agent-next-steps.js +284 -0
- package/dist/command/setup/agent-writer.js +11 -6
- package/dist/command/setup/init/init-host.js +2 -0
- package/dist/command/setup/init/init-runtime.d.ts +6 -1
- package/dist/command/setup/init/init-runtime.js +57 -21
- package/dist/command/setup/mcp-launcher.d.ts +3 -0
- package/dist/command/setup/mcp-launcher.js +11 -0
- package/dist/command/setup/setup-command.js +30 -12
- package/dist/command/setup/setup-mcp.d.ts +8 -0
- package/dist/command/setup/setup-mcp.js +47 -20
- package/dist/command/setup/start-dev-server.d.ts +8 -0
- package/dist/command/setup/start-dev-server.js +43 -0
- package/dist/command/version/identity/server-version.d.ts +2 -0
- package/dist/command/version/identity/server-version.js +2 -0
- package/dist/command/version/version-skew.d.ts +7 -0
- package/dist/command/version/version-skew.js +13 -2
- package/dist/features/license/license.d.ts +11 -71
- package/dist/features/license/license.js +43 -136
- package/dist/features/visual/desktop-capture.d.ts +33 -0
- package/dist/features/visual/desktop-capture.js +94 -0
- package/dist/features/visual/step-shots.js +139 -35
- package/dist/features/visual/visual-tools.d.ts +0 -18
- package/dist/features/visual/visual-tools.js +2 -81
- package/dist/import-graph.js +5 -6
- package/dist/index.d.ts +3 -1
- package/dist/index.js +14 -5
- package/dist/judgement/runs/run-ledger.d.ts +7 -5
- package/dist/judgement/runs/run-ledger.js +98 -69
- package/dist/judgement/runs/verification-sync.d.ts +6 -3
- package/dist/judgement/runs/verification-sync.js +21 -10
- package/dist/language/flows/flow-store-for-session.js +7 -5
- package/dist/language/flows/recording/tape/recordings.d.ts +2 -13
- package/dist/language/flows/recording/tape/recordings.js +41 -44
- package/dist/language/flows/replay.d.ts +17 -7
- package/dist/language/flows/replay.js +95 -44
- package/dist/launch-chromium.d.ts +1 -0
- package/dist/launch-chromium.js +49 -12
- package/dist/memory/cloud/sync-screenshots.js +6 -1
- package/dist/portal/bridge/bridge.d.ts +13 -4
- package/dist/portal/bridge/bridge.js +10 -9
- package/dist/portal/input/drive-error.d.ts +6 -0
- package/dist/portal/input/drive-error.js +9 -0
- package/dist/portal/input/network-mock.js +3 -2
- package/dist/portal/input/real-input-press.d.ts +34 -0
- package/dist/portal/input/real-input-press.js +150 -0
- package/dist/portal/input/real-input.d.ts +21 -23
- package/dist/portal/input/real-input.js +153 -194
- package/dist/portal/input/viewport-tools.js +6 -3
- package/dist/portal/pool/browser-pool.d.ts +5 -1
- package/dist/portal/pool/browser-pool.js +32 -38
- package/dist/portal/pool/context-permissions.d.ts +20 -17
- package/dist/portal/pool/context-permissions.js +63 -1
- package/dist/portal/pool/csp-widen.d.ts +62 -0
- package/dist/portal/pool/csp-widen.js +132 -0
- package/dist/portal/pool/lease-page-error.d.ts +29 -0
- package/dist/portal/pool/lease-page-error.js +18 -0
- package/dist/portal/pool/page-error-journal.d.ts +57 -0
- package/dist/portal/pool/page-error-journal.js +181 -0
- package/dist/portal/pool/playwright-launcher.js +21 -8
- package/dist/portal/pool/pool-contract.d.ts +22 -0
- package/dist/portal/pool/pool-lease.d.ts +35 -2
- package/dist/portal/pool/pool-lease.js +23 -0
- package/dist/portal/pool/zero-install.js +69 -1
- package/dist/portal/session/dev-server/dev-command.d.ts +8 -0
- package/dist/portal/session/dev-server/dev-command.js +56 -8
- package/dist/portal/session/no-session-diagnosis.js +10 -3
- package/dist/portal/session/no-session-next-action.d.ts +12 -0
- package/dist/portal/session/no-session-next-action.js +44 -4
- package/dist/portal/session/no-session-watch.js +3 -1
- package/dist/portal/session/page-commands/undeclared-command.d.ts +9 -3
- package/dist/portal/session/page-commands/undeclared-command.js +16 -7
- package/dist/portal/session/recorded-gaps.d.ts +1 -1
- package/dist/portal/session/recorded-gaps.js +5 -2
- package/dist/portal/session/session-manager.js +40 -21
- package/dist/portal/session/session.js +4 -3
- package/dist/portal/session/zero-install-first-call.d.ts +20 -1
- package/dist/portal/session/zero-install-first-call.js +34 -2
- package/dist/start-wiring.d.ts +29 -2
- package/dist/start-wiring.js +73 -9
- package/dist/surface/mcp/mcp-proxy.js +10 -2
- package/dist/surface/mcp/mcp.js +5 -3
- package/dist/surface/mcp/peer/agents-connected.d.ts +6 -0
- package/dist/surface/mcp/peer/agents-connected.js +58 -0
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +17 -5
- package/dist/surface/tools/act/act-preflight.js +37 -17
- package/dist/surface/tools/act/act-target.js +3 -1
- package/dist/surface/tools/act/after-match.d.ts +7 -0
- package/dist/surface/tools/act/after-match.js +33 -0
- package/dist/surface/tools/act/resolve-target.d.ts +2 -0
- package/dist/surface/tools/act/resolve-target.js +35 -8
- package/dist/surface/tools/act/sequence-grade.d.ts +3 -1
- package/dist/surface/tools/act/sequence-grade.js +8 -1
- package/dist/surface/tools/act-sequence-tool.js +3 -0
- package/dist/surface/tools/act-tools.js +12 -12
- package/dist/surface/tools/assert/assert-verdict.js +28 -1
- package/dist/surface/tools/browser-tools.js +75 -7
- package/dist/surface/tools/dynamic-tools.js +2 -1
- package/dist/surface/tools/empty-session-list.d.ts +2 -0
- package/dist/surface/tools/empty-session-list.js +4 -2
- package/dist/surface/tools/error-recovery.js +43 -22
- package/dist/surface/tools/invoke-tool.js +27 -20
- package/dist/surface/tools/lease-hint.d.ts +7 -0
- package/dist/surface/tools/lease-hint.js +10 -3
- package/dist/surface/tools/lease-readiness.d.ts +21 -0
- package/dist/surface/tools/lease-readiness.js +47 -0
- package/dist/surface/tools/lease-seed.d.ts +2 -0
- package/dist/surface/tools/lease-seed.js +2 -1
- package/dist/surface/tools/lease-tools.js +43 -15
- package/dist/surface/tools/real-input-attempt.js +70 -31
- package/dist/surface/tools/repeat-hint.d.ts +40 -0
- package/dist/surface/tools/repeat-hint.js +171 -0
- package/dist/surface/tools/run-report.d.ts +2 -2
- package/dist/surface/tools/run-report.js +27 -15
- package/dist/surface/tools/tool-kit.d.ts +12 -0
- package/dist/surface/tools/tool-kit.js +7 -0
- package/dist/surface/tools/tools.js +26 -13
- package/dist/telemetry/argument-shape.js +16 -7
- package/dist/telemetry/cli-telemetry.d.ts +10 -1
- package/dist/telemetry/cli-telemetry.js +15 -0
- package/dist/telemetry/init-telemetry.d.ts +2 -1
- package/dist/telemetry/init-telemetry.js +47 -2
- package/dist/telemetry/install-trace.js +23 -4
- package/dist/telemetry/onboarding-funnel.d.ts +19 -2
- package/dist/telemetry/onboarding-funnel.js +25 -3
- package/dist/telemetry/proxy-telemetry.d.ts +2 -1
- package/dist/telemetry/proxy-telemetry.js +2 -2
- package/dist/telemetry/session-metrics.d.ts +1 -1
- package/dist/telemetry/session-metrics.js +67 -27
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +60 -28
- package/dist/telemetry/tool-refused.d.ts +2 -2
- package/dist/telemetry/tool-refused.js +9 -2
- package/docs/architecture.md +3 -6
- package/docs/cli/doctor.mdx +41 -2
- package/docs/cli/help.mdx +4 -2
- package/docs/cli/license.mdx +4 -5
- package/docs/cli/setup.mdx +13 -2
- package/docs/cli/status.mdx +4 -0
- package/docs/cli.mdx +1 -1
- package/docs/deploy-checks.md +25 -0
- package/docs/desktop.mdx +1 -1
- package/docs/enterprise.md +15 -20
- package/docs/environment.mdx +1 -0
- package/docs/first-drive.md +1 -1
- package/docs/install-manual.mdx +16 -1
- package/docs/packages/engine.mdx +1 -1
- package/docs/packages/react.mdx +14 -2
- package/docs/packages/server.mdx +1 -1
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/test.mdx +1 -1
- package/docs/packages/vite-plugin.mdx +1 -0
- package/docs/packages.mdx +1 -1
- package/docs/platform-integration.md +1 -1
- package/docs/skill.md +1 -1
- package/docs/telemetry.md +4 -2
- package/package.json +14 -10
- package/LICENSE-ENTERPRISE +0 -37
- package/dist/features/ee/audit-log.d.ts +0 -14
- package/dist/features/ee/audit-log.js +0 -11
|
@@ -1,142 +1,171 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The run ledger: what
|
|
2
|
+
* The run ledger: what each agent run checked, saved and read, kept until its report is taken.
|
|
3
3
|
*
|
|
4
|
-
* A run is
|
|
4
|
+
* A run is one agent's calls from its first Reticle call to the call that ends it (a session end, a
|
|
5
5
|
* yield, a released tab). The verdicts come from the journal records the agent's own calls wrote;
|
|
6
|
-
* the Harness's are never counted. Taking the report
|
|
7
|
-
* zero.
|
|
6
|
+
* the Harness's are never counted. Taking the report ends that run, so the agent's next run starts
|
|
7
|
+
* at zero.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* call
|
|
9
|
+
* The ledger keeps one run for each MCP attach id. Two agents on one daemon (two worktrees of one
|
|
10
|
+
* project) call in turn. When the ledger kept one run, each call restarted the other agent's run,
|
|
11
|
+
* and a released lease reported the other agent's claims and none of its own. A record goes to the
|
|
12
|
+
* run of the connection that wrote it, and a call that ends a run takes the caller's run (#1900).
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* A record from no connection (the HUD, the CLI, an agent record that ends outside its call
|
|
15
|
+
* context) goes to the run of the agent that called last. An agent that stops without a call that
|
|
16
|
+
* ends its run leaves its run here. That run is dropped when it is idle for `RUN_IDLE_LIMIT_MS`.
|
|
14
17
|
*/
|
|
15
18
|
import { supersededChecks } from '@reticlehq/engine/evidence/superseded-checks.js';
|
|
16
19
|
import { JournalVerdictEffectSchema, ReticleTool, Verified, } from '@reticlehq/core';
|
|
20
|
+
import { callingAttachId } from '../../hooks/coding-agents.js';
|
|
17
21
|
const SAW = 'saw ';
|
|
18
22
|
const FLOW = 'flow ';
|
|
23
|
+
/** A run with no agent call for this long is dropped. A working agent calls much more often. */
|
|
24
|
+
export const RUN_IDLE_LIMIT_MS = 6 * 60 * 60 * 1000;
|
|
19
25
|
/** One piece of evidence for a claim: what the check saw, else the source line it drove. */
|
|
20
26
|
function evidenceOf(effect) {
|
|
21
|
-
if (effect.observed !== undefined)
|
|
27
|
+
if (effect.observed !== undefined) {
|
|
22
28
|
return `${SAW}${effect.observed}`;
|
|
29
|
+
}
|
|
23
30
|
return effect.source;
|
|
24
31
|
}
|
|
25
32
|
/** `no` when anything failed, `yes` only when something held and nothing was undecided. */
|
|
26
33
|
function runVerified(all) {
|
|
27
|
-
if (all.includes(Verified.NO))
|
|
34
|
+
if (all.includes(Verified.NO)) {
|
|
28
35
|
return Verified.NO;
|
|
29
|
-
|
|
36
|
+
}
|
|
37
|
+
if (0 === all.length || all.some((v) => Verified.YES !== v)) {
|
|
30
38
|
return Verified.UNKNOWN;
|
|
39
|
+
}
|
|
31
40
|
return Verified.YES;
|
|
32
41
|
}
|
|
42
|
+
const openRun = (now) => ({
|
|
43
|
+
startedAt: now,
|
|
44
|
+
lastAt: now,
|
|
45
|
+
firstVerdictAt: undefined,
|
|
46
|
+
verdicts: [],
|
|
47
|
+
saved: new Set(),
|
|
48
|
+
replayed: new Map(),
|
|
49
|
+
written: 0,
|
|
50
|
+
read: new Set(),
|
|
51
|
+
});
|
|
33
52
|
export class RunLedger {
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
#verdicts = [];
|
|
38
|
-
#saved = new Set();
|
|
39
|
-
#replayed = new Map();
|
|
40
|
-
#written = 0;
|
|
41
|
-
#read = new Set();
|
|
42
|
-
/** An agent call starts. The first one starts the run, and a call from another agent starts a new one. */
|
|
53
|
+
#runs = new Map();
|
|
54
|
+
#last;
|
|
55
|
+
/** An agent call starts. The agent's first call starts its run. A run idle too long is dropped. */
|
|
43
56
|
begin(now, owner) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
for (const [id, run] of this.#runs) {
|
|
58
|
+
if (now - run.lastAt > RUN_IDLE_LIMIT_MS) {
|
|
59
|
+
this.#runs.delete(id);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const run = this.#runs.get(owner) ?? openRun(now);
|
|
63
|
+
run.lastAt = now;
|
|
64
|
+
this.#runs.set(owner, run);
|
|
65
|
+
this.#last = owner;
|
|
48
66
|
}
|
|
49
67
|
/** An agent call ended. The first one that left a verdict sets the time to the first verdict. */
|
|
50
|
-
after(now) {
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
after(now, owner) {
|
|
69
|
+
const run = this.#run(owner);
|
|
70
|
+
if (run === undefined) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
run.lastAt = Math.max(run.lastAt, now);
|
|
74
|
+
if (0 < run.verdicts.length || 0 < run.replayed.size) {
|
|
75
|
+
run.firstVerdictAt ??= now;
|
|
76
|
+
}
|
|
53
77
|
}
|
|
54
78
|
/** A journal record from the agent's own call. A record with no verdict is not a claim. */
|
|
55
79
|
verdict(action) {
|
|
56
|
-
|
|
80
|
+
const run = this.#run();
|
|
81
|
+
if (run === undefined || action.drivenBy !== undefined) {
|
|
57
82
|
return;
|
|
83
|
+
}
|
|
58
84
|
// A replay is counted once, as a flow, decided as its run decides it (`replayed`). Its journal
|
|
59
85
|
// record said `unknown` where the run said `no`, and one report gave both answers.
|
|
60
|
-
if (ReticleTool.FLOW_REPLAY === action.tool)
|
|
86
|
+
if (ReticleTool.FLOW_REPLAY === action.tool) {
|
|
61
87
|
return;
|
|
88
|
+
}
|
|
62
89
|
const parsed = JournalVerdictEffectSchema.safeParse(action.effect);
|
|
63
|
-
if (parsed.success)
|
|
64
|
-
|
|
90
|
+
if (parsed.success) {
|
|
91
|
+
run.verdicts.push(parsed.data);
|
|
92
|
+
}
|
|
65
93
|
}
|
|
66
94
|
flowSaved(name) {
|
|
67
|
-
|
|
68
|
-
this.#saved.add(name);
|
|
95
|
+
this.#run()?.saved.add(name);
|
|
69
96
|
}
|
|
70
97
|
/** The last replay of a flow in this run is its verdict. */
|
|
71
98
|
replayed(name, verified) {
|
|
72
|
-
|
|
73
|
-
this.#replayed.set(name, verified);
|
|
99
|
+
this.#run()?.replayed.set(name, verified);
|
|
74
100
|
}
|
|
75
101
|
memoryWritten(count = 1) {
|
|
76
|
-
|
|
77
|
-
|
|
102
|
+
const run = this.#run();
|
|
103
|
+
if (run !== undefined) {
|
|
104
|
+
run.written += count;
|
|
105
|
+
}
|
|
78
106
|
}
|
|
79
107
|
/** Notes given to the agent. Each one counts once, however often it is shown. */
|
|
80
108
|
memoryRead(notes) {
|
|
81
|
-
|
|
109
|
+
const run = this.#run();
|
|
110
|
+
if (run === undefined) {
|
|
82
111
|
return;
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
}
|
|
113
|
+
for (const note of notes) {
|
|
114
|
+
run.read.add(note);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
isEmpty(owner) {
|
|
118
|
+
return this.#run(owner) === undefined;
|
|
85
119
|
}
|
|
86
|
-
|
|
87
|
-
|
|
120
|
+
/** The named owner, else the calling connection, else the agent that called last. */
|
|
121
|
+
#owner(owner) {
|
|
122
|
+
return owner ?? callingAttachId() ?? this.#last;
|
|
88
123
|
}
|
|
89
124
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
125
|
+
* The run a record belongs to. None while no agent call has begun one: a CLI `reticle verify`
|
|
126
|
+
* replays through the same code, and what it did is not the next agent's run.
|
|
92
127
|
*/
|
|
93
|
-
#
|
|
94
|
-
|
|
128
|
+
#run(owner) {
|
|
129
|
+
const id = this.#owner(owner);
|
|
130
|
+
return id === undefined ? undefined : this.#runs.get(id);
|
|
95
131
|
}
|
|
96
|
-
/** The report, without the cloud line (the caller knows the project).
|
|
97
|
-
take(now) {
|
|
98
|
-
const
|
|
132
|
+
/** The owner's report, without the cloud line (the caller knows the project). Ends that run. */
|
|
133
|
+
take(now, owner) {
|
|
134
|
+
const id = this.#owner(owner);
|
|
135
|
+
const run = this.#run(owner) ?? openRun(now);
|
|
136
|
+
if (id !== undefined) {
|
|
137
|
+
this.#runs.delete(id);
|
|
138
|
+
}
|
|
139
|
+
const started = run.startedAt;
|
|
99
140
|
// A passive check that read too early is not counted: the rule every fold applies.
|
|
100
|
-
const superseded = supersededChecks(
|
|
141
|
+
const superseded = supersededChecks(run.verdicts.map((v) => ({
|
|
101
142
|
key: v.claim,
|
|
102
143
|
passive: false === v.declaredBeforeActing,
|
|
103
144
|
held: Verified.YES === v.verified,
|
|
104
145
|
failed: Verified.NO === v.verified,
|
|
105
146
|
})));
|
|
106
|
-
const counted =
|
|
147
|
+
const counted = run.verdicts.filter((_, at) => !superseded.has(at));
|
|
107
148
|
const claims = counted.map((v) => {
|
|
108
149
|
const evidence = evidenceOf(v);
|
|
109
150
|
return evidence === undefined
|
|
110
151
|
? { claim: v.claim, verified: v.verified }
|
|
111
152
|
: { claim: v.claim, verified: v.verified, evidence };
|
|
112
153
|
});
|
|
113
|
-
const replayed = [...
|
|
114
|
-
|
|
154
|
+
const replayed = [...run.replayed].map(([name, verified]) => ({ name, verified }));
|
|
155
|
+
return {
|
|
115
156
|
verified: runVerified([...claims.map((c) => c.verified), ...replayed.map((r) => r.verified)]),
|
|
116
157
|
claims,
|
|
117
158
|
caught: [
|
|
118
159
|
...claims.filter((c) => Verified.NO === c.verified).map((c) => c.claim),
|
|
119
160
|
...replayed.filter((r) => Verified.NO === r.verified).map((r) => `${FLOW}${r.name}`),
|
|
120
161
|
],
|
|
121
|
-
flows: { saved: [...
|
|
122
|
-
memory: { written:
|
|
162
|
+
flows: { saved: [...run.saved], replayed },
|
|
163
|
+
memory: { written: run.written, read: run.read.size },
|
|
123
164
|
wallMs: Math.max(0, now - started),
|
|
124
|
-
...(
|
|
165
|
+
...(run.firstVerdictAt === undefined
|
|
125
166
|
? {}
|
|
126
|
-
: { firstVerdictMs: Math.max(0,
|
|
167
|
+
: { firstVerdictMs: Math.max(0, run.firstVerdictAt - started) }),
|
|
127
168
|
};
|
|
128
|
-
this.#reset();
|
|
129
|
-
return report;
|
|
130
|
-
}
|
|
131
|
-
#reset() {
|
|
132
|
-
this.#owner = undefined;
|
|
133
|
-
this.#startedAt = undefined;
|
|
134
|
-
this.#firstVerdictAt = undefined;
|
|
135
|
-
this.#verdicts = [];
|
|
136
|
-
this.#saved = new Set();
|
|
137
|
-
this.#replayed = new Map();
|
|
138
|
-
this.#written = 0;
|
|
139
|
-
this.#read = new Set();
|
|
140
169
|
}
|
|
141
170
|
}
|
|
142
171
|
/** The daemon's ledger. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type FlowFile, type FlowReplayResult } from '@reticlehq/core';
|
|
2
2
|
import { type VerificationRunInput } from './artifact/build-verification-run.js';
|
|
3
|
+
import { AutomationHint } from '@reticlehq/core/telemetry';
|
|
3
4
|
import { type ProjectTarget } from '../../memory/project/session-root.js';
|
|
4
5
|
import type { ToolDeps } from '../../surface/tools/tool-kit.js';
|
|
5
6
|
/** A replay plus the wall-clock time it took — the shape the verify handler already collects. */
|
|
@@ -12,8 +13,10 @@ export interface TimedReplay {
|
|
|
12
13
|
/** The MCP client name `reticle verify` (the command line, CI's path) connects to a daemon as. */
|
|
13
14
|
export declare const CLI_CLIENT_NAME = "reticle-cli";
|
|
14
15
|
/**
|
|
15
|
-
* Who ran the command line, and why.
|
|
16
|
-
*
|
|
16
|
+
* Who ran the command line, and why. Off CI it is the person who holds the key, so the cloud
|
|
17
|
+
* credits them. On CI the `automation` hint says which kind: a CI vendor runner is the pipeline,
|
|
18
|
+
* trigger `ci`; a coding-agent sandbox is a coding agent. A container, or `CI` with no vendor,
|
|
19
|
+
* keeps trigger `oem`, because the cloud has no value for either yet.
|
|
17
20
|
*/
|
|
18
|
-
export declare function cliRunAuthor(env: NodeJS.ProcessEnv): Pick<VerificationRunInput, 'agent' | 'trigger'>;
|
|
21
|
+
export declare function cliRunAuthor(env: NodeJS.ProcessEnv, automation?: AutomationHint | undefined): Pick<VerificationRunInput, 'agent' | 'trigger'>;
|
|
19
22
|
export declare function persistAndSyncVerificationRun(deps: ToolDeps, timed: TimedReplay[], target: ProjectTarget): Promise<string | undefined>;
|
|
@@ -21,26 +21,37 @@ import { RunStore } from './artifact/run-store.js';
|
|
|
21
21
|
import { cloudFetch, syncRunToCloud, SyncOutcome } from '../../memory/cloud/cloud-sync.js';
|
|
22
22
|
import { resolveProjectCloud } from '../../memory/cloud/cloud-config.js';
|
|
23
23
|
import { log } from '../../log.js';
|
|
24
|
-
import { isCiEnv } from '../../telemetry/automation-hint.js';
|
|
24
|
+
import { currentAutomationHint, isCiEnv } from '../../telemetry/automation-hint.js';
|
|
25
|
+
import { AutomationHint } from '@reticlehq/core/telemetry';
|
|
25
26
|
import { rootForTarget } from '../../memory/project/session-root.js';
|
|
26
27
|
/** The author of record when no MCP peer introduced itself — a CLI run, or a client that skipped the handshake. */
|
|
27
28
|
const RETICLE_MCP_AGENT_ID = 'reticle-mcp';
|
|
28
29
|
/** The MCP client name `reticle verify` (the command line, CI's path) connects to a daemon as. */
|
|
29
30
|
export const CLI_CLIENT_NAME = 'reticle-cli';
|
|
30
31
|
/**
|
|
31
|
-
* Who ran the command line, and why.
|
|
32
|
-
*
|
|
32
|
+
* Who ran the command line, and why. Off CI it is the person who holds the key, so the cloud
|
|
33
|
+
* credits them. On CI the `automation` hint says which kind: a CI vendor runner is the pipeline,
|
|
34
|
+
* trigger `ci`; a coding-agent sandbox is a coding agent. A container, or `CI` with no vendor,
|
|
35
|
+
* keeps trigger `oem`, because the cloud has no value for either yet.
|
|
33
36
|
*/
|
|
34
|
-
export function cliRunAuthor(env) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
agent: { id: CLI_CLIENT_NAME, kind: RunAgentKind.OEM_PIPELINE },
|
|
38
|
-
trigger: { kind: RunTrigger.OEM },
|
|
39
|
-
}
|
|
40
|
-
: {
|
|
37
|
+
export function cliRunAuthor(env, automation = currentAutomationHint(env)) {
|
|
38
|
+
if (!isCiEnv(env)) {
|
|
39
|
+
return {
|
|
41
40
|
agent: { id: CLI_CLIENT_NAME, kind: RunAgentKind.HUMAN },
|
|
42
41
|
trigger: { kind: RunTrigger.MANUAL },
|
|
43
42
|
};
|
|
43
|
+
}
|
|
44
|
+
if (AutomationHint.AGENT_SANDBOX === automation) {
|
|
45
|
+
return {
|
|
46
|
+
agent: { id: CLI_CLIENT_NAME, kind: RunAgentKind.CODING_AGENT },
|
|
47
|
+
trigger: { kind: RunTrigger.MANUAL },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const trigger = AutomationHint.CI_RUNNER === automation ? RunTrigger.CI : RunTrigger.OEM;
|
|
51
|
+
return {
|
|
52
|
+
agent: { id: CLI_CLIENT_NAME, kind: RunAgentKind.OEM_PIPELINE },
|
|
53
|
+
trigger: { kind: trigger },
|
|
54
|
+
};
|
|
44
55
|
}
|
|
45
56
|
/** Who ran a suite, and why: the command line by `cliRunAuthor`, any other client as a coding agent. */
|
|
46
57
|
function agentAndTrigger(client) {
|
|
@@ -24,16 +24,18 @@
|
|
|
24
24
|
* returned unchanged.
|
|
25
25
|
*/
|
|
26
26
|
import { FlowStore } from './flows.js';
|
|
27
|
-
import { sessionTarget } from '../../memory/project/session-root.js';
|
|
27
|
+
import { rootForTarget, sessionTarget } from '../../memory/project/session-root.js';
|
|
28
28
|
export function flowsForSession(deps, target) {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Through `rootForTarget`, so the root a lease claimed wins here as it does for runs. Without
|
|
30
|
+
// it a lease from one folder replayed the flows of the folder the daemon was started in.
|
|
31
|
+
const root = rootForTarget(deps, target);
|
|
32
|
+
if (root === deps.reticleRoot) {
|
|
31
33
|
return { flows: deps.flows, root: deps.reticleRoot };
|
|
32
34
|
}
|
|
33
35
|
const onWrote = deps.onRunPersisted;
|
|
34
36
|
return {
|
|
35
|
-
flows: new FlowStore(deps.fs,
|
|
36
|
-
root
|
|
37
|
+
flows: new FlowStore(deps.fs, root, { now: deps.now }, onWrote === undefined ? {} : { onWrote }),
|
|
38
|
+
root,
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
/** `flowsForSession` for a session id, with the project id to scope the store by. Same refusals. */
|
|
@@ -160,8 +160,8 @@ export declare class RecordingStore {
|
|
|
160
160
|
markMocked(session: string | undefined, active: boolean): void;
|
|
161
161
|
/**
|
|
162
162
|
* `forAmbient` is the step as the ambient tape keeps it, when that differs: a journey's refuted
|
|
163
|
-
* claims
|
|
164
|
-
*
|
|
163
|
+
* claims stay off the ambient tape, and a recording somebody opened keeps every claim they
|
|
164
|
+
* declared, held or not. See captureVerdictedAct.
|
|
165
165
|
*/
|
|
166
166
|
capture(step: RecordedStep, route?: string, session?: string, forAmbient?: RecordedStep): void;
|
|
167
167
|
/**
|
|
@@ -178,17 +178,6 @@ export declare class RecordingStore {
|
|
|
178
178
|
* step, replay would ask it right after the click, of a page it was never true on.
|
|
179
179
|
*/
|
|
180
180
|
markNavigated(): void;
|
|
181
|
-
/**
|
|
182
|
-
* Take the expectation off the ambient tape's last step, because the verdict on it came back no.
|
|
183
|
-
*
|
|
184
|
-
* A step is captured at dispatch, before anything is judged, so a claim that turned out false is
|
|
185
|
-
* already on the tape as that step's `expect`. Cut into a journey it becomes a flow that is red for
|
|
186
|
-
* ever, on an expectation nobody but the agent's wrong guess made. The failure is not lost: it is
|
|
187
|
-
* filed as a capsule, the artifact built for "this should hold and does not". The ACTION stays,
|
|
188
|
-
* since the steps after it ran on the page it produced. A recording somebody opened deliberately
|
|
189
|
-
* is left as they drove it.
|
|
190
|
-
*/
|
|
191
|
-
unassertLast(): void;
|
|
192
181
|
/**
|
|
193
182
|
* The page the step just captured ended on, once its action settled.
|
|
194
183
|
*
|
|
@@ -40,8 +40,6 @@ export class RecordingStore {
|
|
|
40
40
|
byHarness;
|
|
41
41
|
#active = new Map();
|
|
42
42
|
#compiled = new Map();
|
|
43
|
-
/** The ambient tape the last step went on: the one a refuted claim is taken off. */
|
|
44
|
-
#lastAmbient;
|
|
45
43
|
/** A navigation came after the last captured step — see markNavigated. */
|
|
46
44
|
#navigatedSinceStep = false;
|
|
47
45
|
/** Tabs with network mocks installed now, keyed by session ('' for none named). See markMocked. */
|
|
@@ -52,8 +50,9 @@ export class RecordingStore {
|
|
|
52
50
|
}
|
|
53
51
|
start(name, cursor, startPath, session, document) {
|
|
54
52
|
const openedOver = new Map();
|
|
55
|
-
for (const [outer, rec] of this.#targets(session))
|
|
53
|
+
for (const [outer, rec] of this.#targets(session)) {
|
|
56
54
|
openedOver.set(outer, rec.steps.length);
|
|
55
|
+
}
|
|
57
56
|
this.#active.set(name, {
|
|
58
57
|
cursor,
|
|
59
58
|
steps: [],
|
|
@@ -96,15 +95,17 @@ export class RecordingStore {
|
|
|
96
95
|
* they are, so the journey it belongs to is not auto-saved as a flow (#1459).
|
|
97
96
|
*/
|
|
98
97
|
markMocked(session, active) {
|
|
99
|
-
if (active)
|
|
98
|
+
if (active) {
|
|
100
99
|
this.#mocked.add(session ?? '');
|
|
101
|
-
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
102
|
this.#mocked.delete(session ?? '');
|
|
103
|
+
}
|
|
103
104
|
}
|
|
104
105
|
/**
|
|
105
106
|
* `forAmbient` is the step as the ambient tape keeps it, when that differs: a journey's refuted
|
|
106
|
-
* claims
|
|
107
|
-
*
|
|
107
|
+
* claims stay off the ambient tape, and a recording somebody opened keeps every claim they
|
|
108
|
+
* declared, held or not. See captureVerdictedAct.
|
|
108
109
|
*/
|
|
109
110
|
capture(step, route, session, forAmbient) {
|
|
110
111
|
this.#navigatedSinceStep = false;
|
|
@@ -118,23 +119,25 @@ export class RecordingStore {
|
|
|
118
119
|
...(session === undefined ? {} : { session }),
|
|
119
120
|
});
|
|
120
121
|
}
|
|
121
|
-
this.#lastAmbient = ambient;
|
|
122
122
|
for (const [name, rec] of this.#targets(session)) {
|
|
123
123
|
// Another tab's tape is that tab's journey, whatever else this step belongs to.
|
|
124
|
-
if (isAmbient(name) && name !== ambient)
|
|
124
|
+
if (isAmbient(name) && name !== ambient) {
|
|
125
125
|
continue;
|
|
126
|
+
}
|
|
126
127
|
// The ambient tape is opened by the system and closed by nobody, so it is the one recording
|
|
127
128
|
// with no human deciding when it has seen enough. Bounded here rather than left to grow for
|
|
128
129
|
// the length of a daemon's life. Appending STOPS at the cap instead of dropping the oldest: a
|
|
129
130
|
// flow replays from its FIRST step, so a tape missing its beginning is not a shorter journey,
|
|
130
131
|
// it is a different one that starts in a state nothing established. A recording somebody
|
|
131
132
|
// opened on purpose is not capped — they said when it starts and they say when it stops.
|
|
132
|
-
if (isAmbient(name) && rec.steps.length >= AMBIENT_STEP_CAP)
|
|
133
|
+
if (isAmbient(name) && rec.steps.length >= AMBIENT_STEP_CAP) {
|
|
133
134
|
continue;
|
|
135
|
+
}
|
|
134
136
|
// The Harness saves its own recordings. Taping its steps too saved every drive twice: once as
|
|
135
137
|
// the Harness's flow and again, at session end, as a `drive-*` copy with a guessed name.
|
|
136
|
-
if (isAmbient(name) && this.byHarness())
|
|
138
|
+
if (isAmbient(name) && this.byHarness()) {
|
|
137
139
|
continue;
|
|
140
|
+
}
|
|
138
141
|
// A step nobody marked ended where the next one began.
|
|
139
142
|
const previous = rec.steps.at(-1);
|
|
140
143
|
if (previous !== undefined && previous.endPage === undefined && step.page !== undefined) {
|
|
@@ -161,26 +164,33 @@ export class RecordingStore {
|
|
|
161
164
|
* the proof. What the step already declared is kept; the new check joins it under `allOf`.
|
|
162
165
|
*/
|
|
163
166
|
attachExpect(expect, session) {
|
|
164
|
-
if (this.#navigatedSinceStep)
|
|
167
|
+
if (this.#navigatedSinceStep) {
|
|
165
168
|
return;
|
|
169
|
+
}
|
|
166
170
|
const mocked = this.#mocked.has(session ?? '');
|
|
167
171
|
for (const [name, rec] of this.#targets(session)) {
|
|
168
|
-
if (isAmbient(name) && name !== ambientRecordingOf(session))
|
|
172
|
+
if (isAmbient(name) && name !== ambientRecordingOf(session)) {
|
|
169
173
|
continue;
|
|
174
|
+
}
|
|
170
175
|
const last = rec.steps.at(-1);
|
|
171
|
-
if (last === undefined)
|
|
176
|
+
if (last === undefined) {
|
|
172
177
|
continue;
|
|
178
|
+
}
|
|
173
179
|
// A proof read while mocks are active rests on mocked responses even when the act before it
|
|
174
180
|
// ran unmocked, so the ambient step it joins is marked too (#1459).
|
|
175
|
-
if (mocked && isAmbient(name))
|
|
181
|
+
if (mocked && isAmbient(name)) {
|
|
176
182
|
last.mocked = true;
|
|
183
|
+
}
|
|
177
184
|
const held = last.expect;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
185
|
+
if (held === undefined) {
|
|
186
|
+
last.expect = expect;
|
|
187
|
+
}
|
|
188
|
+
else if (PredicateKind.ALL_OF === held.kind) {
|
|
189
|
+
last.expect = { ...held, predicates: [...held.predicates, expect] };
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
last.expect = { kind: PredicateKind.ALL_OF, predicates: [held, expect] };
|
|
193
|
+
}
|
|
184
194
|
}
|
|
185
195
|
}
|
|
186
196
|
/**
|
|
@@ -191,24 +201,6 @@ export class RecordingStore {
|
|
|
191
201
|
markNavigated() {
|
|
192
202
|
this.#navigatedSinceStep = true;
|
|
193
203
|
}
|
|
194
|
-
/**
|
|
195
|
-
* Take the expectation off the ambient tape's last step, because the verdict on it came back no.
|
|
196
|
-
*
|
|
197
|
-
* A step is captured at dispatch, before anything is judged, so a claim that turned out false is
|
|
198
|
-
* already on the tape as that step's `expect`. Cut into a journey it becomes a flow that is red for
|
|
199
|
-
* ever, on an expectation nobody but the agent's wrong guess made. The failure is not lost: it is
|
|
200
|
-
* filed as a capsule, the artifact built for "this should hold and does not". The ACTION stays,
|
|
201
|
-
* since the steps after it ran on the page it produced. A recording somebody opened deliberately
|
|
202
|
-
* is left as they drove it.
|
|
203
|
-
*/
|
|
204
|
-
unassertLast() {
|
|
205
|
-
const tape = this.#lastAmbient === undefined ? undefined : this.#active.get(this.#lastAmbient);
|
|
206
|
-
const last = tape?.steps.at(-1);
|
|
207
|
-
if (tape === undefined || last === undefined || last.expect === undefined)
|
|
208
|
-
return;
|
|
209
|
-
const { expect: _dropped, ...kept } = last;
|
|
210
|
-
tape.steps[tape.steps.length - 1] = kept;
|
|
211
|
-
}
|
|
212
204
|
/**
|
|
213
205
|
* The page the step just captured ended on, once its action settled.
|
|
214
206
|
*
|
|
@@ -216,14 +208,17 @@ export class RecordingStore {
|
|
|
216
208
|
* previous step's own answer alone.
|
|
217
209
|
*/
|
|
218
210
|
markEnded(page, session) {
|
|
219
|
-
if (page === undefined)
|
|
211
|
+
if (page === undefined) {
|
|
220
212
|
return;
|
|
213
|
+
}
|
|
221
214
|
for (const [name, rec] of this.#targets(session)) {
|
|
222
|
-
if (isAmbient(name) && name !== ambientRecordingOf(session))
|
|
215
|
+
if (isAmbient(name) && name !== ambientRecordingOf(session)) {
|
|
223
216
|
continue;
|
|
217
|
+
}
|
|
224
218
|
const last = rec.steps.at(-1);
|
|
225
|
-
if (last !== undefined && last.endPage === undefined)
|
|
219
|
+
if (last !== undefined && last.endPage === undefined) {
|
|
226
220
|
last.endPage = page;
|
|
221
|
+
}
|
|
227
222
|
}
|
|
228
223
|
}
|
|
229
224
|
/**
|
|
@@ -241,14 +236,16 @@ export class RecordingStore {
|
|
|
241
236
|
stop(name) {
|
|
242
237
|
const rec = this.#active.get(name);
|
|
243
238
|
this.#active.delete(name);
|
|
244
|
-
if (rec === undefined)
|
|
239
|
+
if (rec === undefined) {
|
|
245
240
|
return undefined;
|
|
241
|
+
}
|
|
246
242
|
for (const [outer, mark] of rec.openedOver) {
|
|
247
243
|
const parent = this.#active.get(outer);
|
|
248
244
|
// Gone already, or somehow shorter than when we started: leave it exactly as it is rather
|
|
249
245
|
// than splice a range that no longer means what it meant.
|
|
250
|
-
if (parent === undefined || parent.steps.length < mark)
|
|
246
|
+
if (parent === undefined || parent.steps.length < mark) {
|
|
251
247
|
continue;
|
|
248
|
+
}
|
|
252
249
|
parent.steps.splice(mark, parent.steps.length - mark, {
|
|
253
250
|
tool: INVOKE_TOOL,
|
|
254
251
|
args: { flow: name },
|
|
@@ -55,21 +55,31 @@ export declare const UNKEPT_EXPECT: string;
|
|
|
55
55
|
*/
|
|
56
56
|
export declare function captureAct(recordings: {
|
|
57
57
|
active: () => string[];
|
|
58
|
-
capture: (step: RecordedStep, route?: string, session?: string) => void;
|
|
58
|
+
capture: (step: RecordedStep, route?: string, session?: string, forAmbient?: RecordedStep) => void;
|
|
59
59
|
}, args: Record<string, unknown>, res: unknown,
|
|
60
60
|
/** Where this step ran, so the ambient tape can be cut into journeys. See RecordedStep.route. */
|
|
61
61
|
route?: string,
|
|
62
62
|
/** The session that acted: only recordings it started take the step. */
|
|
63
63
|
session?: string,
|
|
64
64
|
/** The act's window sent a write request; see `wroteIn`. */
|
|
65
|
-
wrote?: boolean
|
|
65
|
+
wrote?: boolean,
|
|
66
|
+
/** The act as the ambient tape keeps it, when that differs. See RecordingStore.capture. */
|
|
67
|
+
ambientArgs?: Record<string, unknown>): void;
|
|
66
68
|
/**
|
|
67
|
-
* Capture an act_and_wait once its verdict is known
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
69
|
+
* Capture an act_and_wait once its verdict is known.
|
|
70
|
+
*
|
|
71
|
+
* The rule act_sequence keeps (#988). A recording somebody opened keeps every claim the agent
|
|
72
|
+
* declared, held or not: dropping a refuted one saved a flow that replayed green on the defect it
|
|
73
|
+
* had caught. The ambient tape keeps a claim only if the action made it hold (`held`), so a flow cut
|
|
74
|
+
* from it never replays a claim that was never once true because of the action. The action is
|
|
75
|
+
* always recorded: replay needs it to reach the next step.
|
|
76
|
+
*
|
|
77
|
+
* Keyed on `held`, not on `verified: yes`. A claim the action made hold under an `unknown` or a
|
|
78
|
+
* contradicted verdict is still what the agent proved about the page, and dropping it left the
|
|
79
|
+
* replay with no check at all. One function decides both tapes: a second rule that took the claim off the tape
|
|
80
|
+
* after the fact ran before this capture, so it took the PREVIOUS step's claim, which had passed.
|
|
71
81
|
*/
|
|
72
|
-
export declare function captureVerdictedAct(recordings: Parameters<typeof captureAct>[0], args: Record<string, unknown>, res: unknown, route: string | undefined,
|
|
82
|
+
export declare function captureVerdictedAct(recordings: Parameters<typeof captureAct>[0], args: Record<string, unknown>, res: unknown, route: string | undefined, held: boolean, session?: string,
|
|
73
83
|
/** The act's window sent a write request; see `wroteIn`. */
|
|
74
84
|
wrote?: boolean): void;
|
|
75
85
|
/** Fold a PASSING standalone assertion into the step it proved. See RecordingStore.attachExpect. */
|