@reticlehq/server 3.2.0 → 3.3.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/SKILL.md +16 -13
- package/dist/command/cli/adhoc-suite.d.ts +19 -0
- package/dist/command/cli/adhoc-suite.js +88 -0
- package/dist/command/cli/adhoc-verdict.d.ts +23 -2
- package/dist/command/cli/adhoc-verdict.js +18 -10
- package/dist/command/cli/cli-doctor.js +25 -5
- package/dist/command/cli/cli-parse-grammar.d.ts +7 -0
- package/dist/command/cli/cli-parse-grammar.js +7 -0
- package/dist/command/cli/cli-parse-verify.d.ts +2 -5
- package/dist/command/cli/cli-parse-verify.js +86 -13
- package/dist/command/cli/cli-parse.d.ts +7 -1
- package/dist/command/cli/cli-parse.js +33 -5
- package/dist/command/cli/cli-verify.d.ts +59 -0
- package/dist/command/cli/cli-verify.js +129 -17
- package/dist/command/cli/cloud-kit.d.ts +0 -2
- package/dist/command/cli/cloud-kit.js +1 -1
- package/dist/command/cli/doctor/doctor-sessions-line.d.ts +2 -1
- package/dist/command/cli/launch/cli-launch.d.ts +1 -29
- package/dist/command/cli/launch/cli-launch.js +6 -40
- package/dist/command/cli/ports/resolve/cli-port.d.ts +13 -0
- package/dist/command/cli/ports/resolve/cli-port.js +45 -1
- package/dist/command/cli/report-command.d.ts +16 -0
- package/dist/command/cli/report-command.js +97 -0
- package/dist/command/cli.js +5 -0
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +30 -0
- package/dist/command/daemon/binding/daemon-status-probe.js +45 -0
- package/dist/command/daemon/binding/port-presence.d.ts +1 -1
- package/dist/command/daemon/idle-shutdown.d.ts +2 -1
- package/dist/command/setup/init/init-host.js +8 -0
- package/dist/features/crawl/crawl.d.ts +2 -0
- package/dist/features/crawl/crawl.js +1 -0
- package/dist/features/harness/jev-driver.js +6 -9
- package/dist/features/harness/platform-config.d.ts +10 -0
- package/dist/features/harness/platform-config.js +8 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +40 -95
- package/dist/judgement/capsule/capsule-store.d.ts +31 -2
- package/dist/judgement/capsule/capsule-store.js +58 -6
- package/dist/judgement/capsule/capsule.js +5 -0
- package/dist/judgement/capsule/causal-summary.d.ts +13 -1
- package/dist/judgement/capsule/causal-summary.js +7 -0
- package/dist/judgement/domain/domain-model.js +38 -9
- package/dist/judgement/outcome/assertion-integrity.d.ts +3 -3
- package/dist/judgement/phenomena/phenomena.d.ts +2 -1
- package/dist/judgement/runs/artifact/gap-report.d.ts +17 -0
- package/dist/judgement/runs/artifact/gap-report.js +46 -0
- package/dist/judgement/runs/artifact/gap-summary.d.ts +58 -0
- package/dist/judgement/runs/artifact/gap-summary.js +85 -0
- package/dist/judgement/runs/artifact/render-report.js +4 -1
- package/dist/judgement/runs/artifact/to-artifact.d.ts +7 -2
- package/dist/judgement/runs/artifact/to-artifact.js +1 -1
- package/dist/judgement/runs/context-tools.js +21 -3
- package/dist/judgement/runs/drive-run.js +7 -2
- package/dist/language/flows/annotate-notes/annotate.d.ts +0 -15
- package/dist/language/flows/annotate-notes/annotate.js +66 -18
- package/dist/language/flows/flow-anchor.d.ts +12 -0
- package/dist/language/flows/flow-anchor.js +20 -0
- package/dist/language/flows/flow-expect-grammar.d.ts +2 -2
- package/dist/language/flows/flow-expect-grammar.js +92 -11
- package/dist/language/flows/flow-intent.js +1 -1
- package/dist/language/flows/flow-learning.d.ts +3 -0
- package/dist/language/flows/flow-learning.js +39 -8
- package/dist/language/flows/flow-replay-run.d.ts +31 -9
- package/dist/language/flows/flow-replay-run.js +146 -33
- package/dist/language/flows/flow-replay-types.d.ts +9 -0
- package/dist/language/flows/flow-replay.d.ts +12 -11
- package/dist/language/flows/flow-replay.js +79 -109
- package/dist/language/flows/flow-step-runners.d.ts +26 -10
- package/dist/language/flows/flow-step-runners.js +20 -4
- package/dist/language/flows/flow-success.d.ts +21 -6
- package/dist/language/flows/flow-success.js +50 -138
- package/dist/language/flows/flows.d.ts +8 -4
- package/dist/language/flows/flows.js +11 -2
- package/dist/language/flows/heal.d.ts +9 -1
- package/dist/language/flows/heal.js +24 -2
- package/dist/language/flows/learned-sources.d.ts +21 -0
- package/dist/language/flows/learned-sources.js +84 -0
- package/dist/language/flows/recording/recording-backtrack.d.ts +0 -7
- package/dist/language/flows/recording/recording-backtrack.js +1 -1
- package/dist/language/flows/recording/replay-halt.d.ts +2 -1
- package/dist/language/flows/recording/tape/recordings.d.ts +2 -2
- package/dist/language/flows/replay.js +12 -8
- package/dist/language/flows/stores/annotation-store.d.ts +5 -5
- package/dist/language/flows/stores/assertion-tiers-store.js +2 -3
- package/dist/language/flows/stores/flake-store.js +2 -3
- package/dist/language/flows/suite/coverage.d.ts +1 -1
- package/dist/language/flows/suite/parallel-suite.d.ts +2 -1
- package/dist/language/flows/verify-change-tools.js +14 -0
- package/dist/memory/cloud/cloud-sync.d.ts +0 -2
- package/dist/memory/cloud/cloud-sync.js +1 -1
- package/dist/memory/cloud/harness-config.d.ts +2 -0
- package/dist/memory/cloud/sync-cycle.d.ts +12 -0
- package/dist/memory/cloud/sync-cycle.js +29 -4
- package/dist/memory/cloud/sync-daemon.d.ts +13 -0
- package/dist/memory/cloud/sync-daemon.js +21 -0
- package/dist/memory/cloud/sync-disk.js +11 -6
- package/dist/memory/impact/impact-store.js +6 -0
- package/dist/memory/intent/inline-intent.js +8 -3
- package/dist/memory/intent/intent-shard-store.d.ts +33 -47
- package/dist/memory/intent/intent-shard-store.js +252 -140
- package/dist/memory/intent/intent-shard.d.ts +10 -11
- package/dist/memory/intent/intent-shard.js +1 -1
- package/dist/memory/intent/intent-store.d.ts +13 -8
- package/dist/memory/intent/intent-store.js +46 -90
- package/dist/memory/intent/intent-tools.js +34 -23
- package/dist/memory/journal/ambient-store.js +2 -3
- package/dist/memory/journal/deviation-report.js +4 -1
- package/dist/memory/journal/deviation-service.js +10 -2
- package/dist/memory/journal/drive-flow.d.ts +2 -2
- package/dist/memory/journal/drive-flow.js +21 -12
- package/dist/memory/journal/envelope-store.js +2 -3
- package/dist/memory/journal/journal-recorder.d.ts +21 -0
- package/dist/memory/journal/on-disk/retain-policy.d.ts +34 -0
- package/dist/memory/journal/on-disk/retain-policy.js +44 -0
- package/dist/memory/journal/on-disk/retention.d.ts +14 -10
- package/dist/memory/journal/on-disk/retention.js +13 -12
- package/dist/memory/journal/on-disk/startup-maintenance.d.ts +42 -0
- package/dist/memory/journal/on-disk/startup-maintenance.js +14 -0
- package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +0 -11
- package/dist/memory/journal/on-disk/workspace-gitignore.js +6 -45
- package/dist/memory/journal/on-disk/workspace-tiers.d.ts +52 -0
- package/dist/memory/journal/on-disk/workspace-tiers.js +88 -0
- package/dist/memory/journal/route-template.d.ts +22 -0
- package/dist/memory/journal/route-template.js +44 -0
- package/dist/memory/journal/session-end.d.ts +4 -1
- package/dist/memory/journal/session-end.js +64 -6
- package/dist/memory/journal/session-journal.d.ts +8 -1
- package/dist/memory/journal/session-journal.js +10 -0
- package/dist/memory/project/baselines.d.ts +1 -1
- package/dist/memory/project/dir/reticle-dir.d.ts +1 -1
- package/dist/memory/project/fs/fs-port.js +44 -5
- package/dist/memory/project/fs/write-atomic.d.ts +26 -0
- package/dist/memory/project/fs/write-atomic.js +48 -0
- package/dist/memory/recall/prior/connection-memory.d.ts +6 -0
- package/dist/memory/recall/prior/connection-memory.js +8 -0
- package/dist/memory/recall/registered-projects.d.ts +2 -1
- package/dist/portal/bridge/auth-failure-reason.js +5 -2
- package/dist/portal/input/real-input.js +2 -1
- package/dist/portal/pool/browser-pool.d.ts +10 -0
- package/dist/portal/pool/browser-pool.js +10 -0
- package/dist/portal/pool/playwright-launcher.js +1 -0
- package/dist/portal/pool/pool-contract.d.ts +2 -0
- package/dist/portal/pool/storage-seed.d.ts +2 -1
- package/dist/portal/pool/zero-install.d.ts +27 -0
- package/dist/portal/pool/zero-install.js +54 -0
- package/dist/portal/realm/web-realm.js +6 -0
- package/dist/portal/session/dev-server/dev-server-probe.d.ts +2 -1
- package/dist/portal/session/dev-server/served-document.d.ts +21 -0
- package/dist/portal/session/dev-server/served-document.js +57 -0
- package/dist/portal/session/facts/background-traffic.d.ts +9 -0
- package/dist/portal/session/facts/background-traffic.js +25 -0
- package/dist/portal/session/live-control-tools.js +48 -3
- package/dist/portal/session/presence/attachment-history.d.ts +13 -2
- package/dist/portal/session/session-health.d.ts +61 -0
- package/dist/portal/session/session-health.js +40 -1
- package/dist/portal/session/session-info.d.ts +6 -0
- package/dist/portal/session/session-manager.d.ts +9 -0
- package/dist/portal/session/session-manager.js +23 -2
- package/dist/portal/session/session-successor.d.ts +11 -0
- package/dist/portal/session/session-successor.js +14 -1
- package/dist/portal/session/session-verdict-facts.d.ts +28 -0
- package/dist/portal/session/session-verdict-facts.js +15 -0
- package/dist/portal/session/session.d.ts +5 -0
- package/dist/portal/session/session.js +19 -7
- package/dist/surface/mcp/mcp-proxy.js +3 -3
- package/dist/surface/mcp/proxy-idle-exit.d.ts +2 -1
- package/dist/surface/mcp/server-instructions.js +12 -1
- package/dist/surface/tools/act/already-true.d.ts +40 -0
- package/dist/surface/tools/act/already-true.js +36 -0
- package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +6 -0
- package/dist/surface/tools/act/navigation/navigate-arrival.js +13 -0
- package/dist/surface/tools/act/settle-in-flight.d.ts +6 -2
- package/dist/surface/tools/act/settle-in-flight.js +29 -6
- package/dist/surface/tools/act-capsule.js +22 -7
- package/dist/surface/tools/act-tools.js +25 -24
- package/dist/surface/tools/args/numeric-bounds.d.ts +15 -2
- package/dist/surface/tools/args/numeric-bounds.js +30 -3
- package/dist/surface/tools/assert/assert-grade.js +14 -2
- package/dist/surface/tools/assert/assert-verdict.js +30 -4
- package/dist/surface/tools/browser-tools.js +4 -3
- package/dist/surface/tools/dynamic-tools.js +6 -0
- package/dist/surface/tools/feedback-tools.d.ts +2 -1
- package/dist/surface/tools/feedback-tools.js +1 -1
- package/dist/surface/tools/invoke-tool.js +17 -4
- package/dist/surface/tools/lease-hint.d.ts +8 -0
- package/dist/surface/tools/lease-hint.js +10 -0
- package/dist/surface/tools/lease-tools.d.ts +16 -28
- package/dist/surface/tools/lease-tools.js +53 -5
- package/dist/surface/tools/lineage-tools.js +1 -1
- package/dist/surface/tools/observe-tools.js +14 -6
- package/dist/surface/tools/real-input-attempt.d.ts +0 -5
- package/dist/surface/tools/real-input-attempt.js +1 -1
- package/dist/surface/tools/tool-kit.d.ts +5 -0
- package/dist/surface/tools/tool-kit.js +6 -1
- package/dist/surface/tools/tool-surface.js +17 -5
- package/dist/surface/tools/tools.js +64 -29
- package/dist/telemetry/feedback-local.d.ts +1 -0
- package/dist/telemetry/feedback-local.js +1 -3
- package/dist/telemetry/feedback.d.ts +1 -1
- package/dist/telemetry/hud-metrics.d.ts +32 -0
- package/dist/telemetry/hud-metrics.js +95 -0
- package/dist/telemetry/session-metrics.d.ts +5 -0
- package/dist/telemetry/session-metrics.js +15 -1
- package/dist/wire-journal.d.ts +31 -0
- package/dist/wire-journal.js +100 -0
- package/docs/README.md +1 -0
- package/docs/cli/cloud.mdx +9 -7
- package/docs/cli/init.mdx +1 -0
- package/docs/cli/report.mdx +44 -0
- package/docs/desktop.mdx +1 -1
- package/docs/flows.md +11 -0
- package/docs/frameworks.mdx +113 -1
- package/docs/install-agentic.mdx +36 -0
- package/docs/install-manual.mdx +1 -1
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/predicates.mdx +128 -4
- package/docs/tools/query.mdx +10 -10
- package/docs/usage.md +1 -1
- package/docs/what-is-recorded.md +62 -0
- package/package.json +6 -6
- package/dist/judgement/outcome/predicate-to-expect.d.ts +0 -44
- package/dist/judgement/outcome/predicate-to-expect.js +0 -148
|
@@ -1,11 +1,46 @@
|
|
|
1
1
|
import { access, appendFile, mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
2
2
|
/** The JSONL record terminator, as a byte — the only boundary a capped tail read can align to. */
|
|
3
3
|
const NEWLINE_BYTE = 0x0a;
|
|
4
|
+
/**
|
|
5
|
+
* The largest single `read(2)` this port will ask for: 2^31 - 1, the int32 ceiling.
|
|
6
|
+
*
|
|
7
|
+
* Past it `fs.read` does not throw — it ABORTS the process on a V8 assertion
|
|
8
|
+
* (`node::fs::Read ... Assertion failed: args[3]->IsInt32()`, where args[3] is the length). That is
|
|
9
|
+
* not catchable, so one oversized journal takes the daemon down and with it every session on the
|
|
10
|
+
* machine, including unrelated ones. Reported from the field exactly that way.
|
|
11
|
+
*
|
|
12
|
+
* Reachable in practice because `Buffer.allocUnsafe` happily allocates well past this, so a
|
|
13
|
+
* `size - start` over 2 GiB allocates fine and then kills node at the syscall. A `.reticle/sessions`
|
|
14
|
+
* has been observed in the multi-gigabyte range, so "no journal is ever that big" is not a bound
|
|
15
|
+
* anybody is enforcing.
|
|
16
|
+
*
|
|
17
|
+
* Clamping rather than throwing is deliberate: a caller reading a tail wants the newest records, and
|
|
18
|
+
* returning the last 2 GiB of them is a better answer than an error. The boundary realignment below
|
|
19
|
+
* already handles a window that opens mid-record, which is exactly what a clamp produces.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_SINGLE_READ_BYTES = 2_147_483_647;
|
|
22
|
+
/**
|
|
23
|
+
* Refuse an offset that cannot survive the arithmetic below.
|
|
24
|
+
*
|
|
25
|
+
* `length = size - start`, so a non-integer or negative offset produces a non-integer or negative
|
|
26
|
+
* length, and those fail in two different ways one layer apart: `Buffer.allocUnsafe` throws a
|
|
27
|
+
* RangeError for a fractional length, while an oversized one reaches `fs.read` and aborts the
|
|
28
|
+
* process. Checking here means the caller gets one predictable rejection instead of either.
|
|
29
|
+
*/
|
|
30
|
+
function requireByteCount(label, value) {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
return;
|
|
33
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
34
|
+
throw new RangeError(`${label} must be a non-negative safe integer, got ${String(value)} — a fractional or negative byte count cannot reach a read syscall safely`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
4
37
|
/** Production adapter — the single import site of node:fs/promises in the server. */
|
|
5
38
|
export function createNodeFileSystem() {
|
|
6
39
|
return {
|
|
7
40
|
readFile: (path) => readFile(path, 'utf8'),
|
|
8
41
|
readFileFrom: async (path, byteOffset, maxBytes) => {
|
|
42
|
+
requireByteCount('byteOffset', byteOffset);
|
|
43
|
+
requireByteCount('maxBytes', maxBytes);
|
|
9
44
|
const fh = await open(path, 'r');
|
|
10
45
|
try {
|
|
11
46
|
const { size } = await fh.stat();
|
|
@@ -18,14 +53,18 @@ export function createNodeFileSystem() {
|
|
|
18
53
|
// One byte BEFORE that cut, so a cut landing exactly on a record boundary is recognised as
|
|
19
54
|
// one instead of sacrificing the record after it.
|
|
20
55
|
const start = capped ? cut - 1 : byteOffset;
|
|
21
|
-
|
|
56
|
+
// Clamped to the int32 ceiling: past it `fs.read` aborts the process rather than throwing.
|
|
57
|
+
// Keep the NEWEST bytes, matching what every caller of a tail read is asking for.
|
|
58
|
+
const want = size - start;
|
|
59
|
+
const length = Math.min(want, MAX_SINGLE_READ_BYTES);
|
|
60
|
+
const readFrom = start + (want - length);
|
|
22
61
|
const buf = Buffer.allocUnsafe(length);
|
|
23
|
-
const { bytesRead } = await fh.read(buf, 0, length,
|
|
62
|
+
const { bytesRead } = await fh.read(buf, 0, length, readFrom);
|
|
24
63
|
// ONLY what the read returned. `allocUnsafe` hands back whatever was last in that heap
|
|
25
64
|
// block, and `fstat` runs one syscall ahead of `read` — so a file truncated in between, or
|
|
26
65
|
// any short read, would otherwise decode that memory and hand it back as journal text.
|
|
27
66
|
const got = buf.subarray(0, bytesRead);
|
|
28
|
-
if (!capped)
|
|
67
|
+
if (!capped && readFrom === start)
|
|
29
68
|
return { text: got.toString('utf8'), size, from: start };
|
|
30
69
|
// The ceiling moved the start, so the window may open inside a record whose head was never
|
|
31
70
|
// read. Drop up to the first boundary in BYTES, before decoding: `\n` is one byte and
|
|
@@ -35,11 +74,11 @@ export function createNodeFileSystem() {
|
|
|
35
74
|
// No boundary in the whole window: there is no record here that can be read, and half of
|
|
36
75
|
// one is worse than none. `from` still says how far the read reached.
|
|
37
76
|
if (-1 === newline)
|
|
38
|
-
return { text: '', size, from:
|
|
77
|
+
return { text: '', size, from: readFrom + bytesRead };
|
|
39
78
|
return {
|
|
40
79
|
text: got.subarray(newline + 1).toString('utf8'),
|
|
41
80
|
size,
|
|
42
|
-
from:
|
|
81
|
+
from: readFrom + newline + 1,
|
|
43
82
|
};
|
|
44
83
|
}
|
|
45
84
|
finally {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write a file so a reader never sees a half-written one, and two writers never see each other's.
|
|
3
|
+
*
|
|
4
|
+
* Write to a temporary sibling, then rename. The rename is what makes it atomic: on a POSIX
|
|
5
|
+
* filesystem it either replaces the destination entirely or does not happen, so a reader gets the
|
|
6
|
+
* old file or the new one and never a truncated middle.
|
|
7
|
+
*
|
|
8
|
+
* TWO properties, and the second is the one that was missing. Five stores in this package built
|
|
9
|
+
* this by hand and named the temp file after the DESTINATION — `${path}.tmp` — which is shared by
|
|
10
|
+
* every process writing that path. Two daemons on one repository then write the same temp file:
|
|
11
|
+
* one truncates the other mid-write and renames the remains into place, and the survivor is a file
|
|
12
|
+
* neither of them produced. Two other stores already pid-suffixed theirs, so the fix was known and
|
|
13
|
+
* had simply never been carried to the rest.
|
|
14
|
+
*
|
|
15
|
+
* Running parallel agent sessions against one checkout is the ordinary way to reach this, not an
|
|
16
|
+
* exotic one.
|
|
17
|
+
*
|
|
18
|
+
* The temp file stays BESIDE its destination rather than in the system temp directory, because a
|
|
19
|
+
* rename across devices is not atomic and `/tmp` is routinely a different device.
|
|
20
|
+
*
|
|
21
|
+
* One helper rather than five corrected copies: the sixth store gets this for free and cannot get
|
|
22
|
+
* it wrong, which is the only version of this fix that stays fixed.
|
|
23
|
+
*/
|
|
24
|
+
import type { FileSystemPort } from './fs-port.js';
|
|
25
|
+
export declare function tempNameFor(path: string, pid?: number, seq?: number): string;
|
|
26
|
+
export declare function writeFileAtomic(fs: FileSystemPort, path: string, contents: string): Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unique per WRITE, not per process.
|
|
3
|
+
*
|
|
4
|
+
* The pid separates two daemons. It does not separate two concurrent writes inside ONE daemon —
|
|
5
|
+
* `Promise.all` over two store saves, or two scheduled flushes of the same ledger — and those share
|
|
6
|
+
* a pid, so a pid-only name puts them straight back on the same temp file. The first version of
|
|
7
|
+
* this helper had exactly that hole and its own concurrency test found it.
|
|
8
|
+
*
|
|
9
|
+
* A counter rather than `Math.random()`: rule 7 keeps the clock and the random source out of pure
|
|
10
|
+
* logic, and a monotonic counter is deterministic, collision-free within a process, and needs
|
|
11
|
+
* neither.
|
|
12
|
+
*/
|
|
13
|
+
let writeSeq = 0;
|
|
14
|
+
export function tempNameFor(path, pid = process.pid, seq) {
|
|
15
|
+
writeSeq += 1;
|
|
16
|
+
return `${path}.${String(pid)}.${String(seq ?? writeSeq)}.tmp`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* What Windows answers, for a moment, when another writer is still replacing the destination.
|
|
20
|
+
* POSIX never refuses such a rename; Windows does, and the save has to wait it out rather than
|
|
21
|
+
* fail. Seen on Windows CI with two writers on one path.
|
|
22
|
+
*/
|
|
23
|
+
const TRANSIENT_RENAME = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
24
|
+
const RENAME_ATTEMPTS = 6;
|
|
25
|
+
const RENAME_BACKOFF_MS = 15;
|
|
26
|
+
function isTransientRename(error) {
|
|
27
|
+
const code = error?.code;
|
|
28
|
+
return 'string' === typeof code && TRANSIENT_RENAME.has(code);
|
|
29
|
+
}
|
|
30
|
+
export async function writeFileAtomic(fs, path, contents) {
|
|
31
|
+
const tmp = tempNameFor(path);
|
|
32
|
+
await fs.writeFile(tmp, contents);
|
|
33
|
+
for (let attempt = 1;; attempt += 1) {
|
|
34
|
+
try {
|
|
35
|
+
await fs.rename(tmp, path);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (attempt < RENAME_ATTEMPTS && isTransientRename(error)) {
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, RENAME_BACKOFF_MS * attempt));
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// The write failed; its temp file must not outlive it.
|
|
44
|
+
await fs.rm(tmp).catch(() => undefined);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -63,3 +63,9 @@ export declare function hasAnyAppConnectedBefore(stateDir: string, port: number,
|
|
|
63
63
|
* diagnosis, which is why it now shares this function instead of keeping a second copy of the rule.
|
|
64
64
|
*/
|
|
65
65
|
export declare function hasProjectConnectedBefore(stateDir: string, port: number, projectId: string | undefined): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Has any of these projects connected before: the first-move question for a directory that speaks
|
|
68
|
+
* for more than one app (a monorepo root), or for none by name (an app wired by the plugin alone).
|
|
69
|
+
* The ids come from `projectIdsAt`; each is still asked the narrow question.
|
|
70
|
+
*/
|
|
71
|
+
export declare function hasAnyProjectConnectedBefore(stateDir: string, port: number, projectIds: readonly string[]): boolean;
|
|
@@ -112,3 +112,11 @@ export function hasProjectConnectedBefore(stateDir, port, projectId) {
|
|
|
112
112
|
return false;
|
|
113
113
|
return hasAnyAppConnectedBefore(stateDir, port, projectId);
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Has any of these projects connected before: the first-move question for a directory that speaks
|
|
117
|
+
* for more than one app (a monorepo root), or for none by name (an app wired by the plugin alone).
|
|
118
|
+
* The ids come from `projectIdsAt`; each is still asked the narrow question.
|
|
119
|
+
*/
|
|
120
|
+
export function hasAnyProjectConnectedBefore(stateDir, port, projectIds) {
|
|
121
|
+
return projectIds.some((id) => hasProjectConnectedBefore(stateDir, port, id));
|
|
122
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** One project this machine has paired before, in the shape the no-session facts already carry. */
|
|
2
|
-
|
|
2
|
+
interface RegisteredProject {
|
|
3
3
|
directory: string;
|
|
4
4
|
projectId?: string;
|
|
5
5
|
}
|
|
@@ -52,3 +52,4 @@ export declare function isUnscopedRoot(directory: string, home: string): boolean
|
|
|
52
52
|
* reporting it as such would tell a correctly-scoped daemon to go and look somewhere else.
|
|
53
53
|
*/
|
|
54
54
|
export declare function registeredElsewhere(home: string, directory: string): RegisteredProject[];
|
|
55
|
+
export {};
|
|
@@ -28,8 +28,11 @@ const PLAIN = 'authentication failed';
|
|
|
28
28
|
* before `~/.reticle` was replaced. Both need the same first move: ask `status`, which can say which.
|
|
29
29
|
*/
|
|
30
30
|
const WRONG_TOKEN = 'authentication failed: wrong pairing token — run `reticle status` for the cause';
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A paste-in snippet or a Next config that never saw a token. Reload cannot mint one; restarting the
|
|
33
|
+
* dev server re-runs the substitution that should have put it there (#1047).
|
|
34
|
+
*/
|
|
35
|
+
const NO_TOKEN = 'authentication failed: no pairing token on the page; the build did not substitute it, so restart the dev server';
|
|
33
36
|
/** WebSocket close reasons are capped at 123 bytes; a longer one throws and closes with nothing. */
|
|
34
37
|
const MAX_REASON_BYTES = 123;
|
|
35
38
|
const DIFFERENT_PROJECT_PREFIX = 'this daemon serves a different project';
|
|
@@ -7,6 +7,7 @@ import { classifyConnectFailure } from '../../telemetry/connect-failure.js';
|
|
|
7
7
|
import { ActionType, DriveErrorCode, DRIVE_PLAYWRIGHT_MISSING_MSG } from '@reticlehq/core';
|
|
8
8
|
import { installNetworkMocks } from './network-mock.js';
|
|
9
9
|
import { attachNetworkDetail } from './network-detail.js';
|
|
10
|
+
import { injectedConnectArgs } from '../pool/zero-install.js';
|
|
10
11
|
/** Structured, code-tagged failure so callers branch on cause, not message text. */
|
|
11
12
|
export class DriveError extends Error {
|
|
12
13
|
code;
|
|
@@ -338,7 +339,7 @@ export class LaunchedRealInputProvider {
|
|
|
338
339
|
await page.waitForFunction('!!globalThis.__reticleInstance', {
|
|
339
340
|
timeout: INJECT_CONNECT_WAIT_MS,
|
|
340
341
|
});
|
|
341
|
-
const arg = JSON.stringify(
|
|
342
|
+
const arg = JSON.stringify(injectedConnectArgs(opts));
|
|
342
343
|
await page.evaluate(`globalThis.__reticleInstance.connect(${arg})`);
|
|
343
344
|
}
|
|
344
345
|
catch {
|
|
@@ -18,6 +18,11 @@ export interface Lease {
|
|
|
18
18
|
readonly url: string;
|
|
19
19
|
readonly navStatus?: number;
|
|
20
20
|
release(): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Hand the page the zero-install reader: evaluated in the live document, and added ahead of every
|
|
23
|
+
* document this lease loads next. False when there is no reader, or the page cannot run one.
|
|
24
|
+
*/
|
|
25
|
+
injectReader?(): Promise<boolean>;
|
|
21
26
|
}
|
|
22
27
|
/** Default lease time-to-live: a lease untouched for this long is presumed orphaned and reclaimed. */
|
|
23
28
|
export declare const DEFAULT_LEASE_TTL_MS: number;
|
|
@@ -32,6 +37,11 @@ interface BrowserPoolOptions {
|
|
|
32
37
|
leaseTtlMs?: number;
|
|
33
38
|
/** Per-lease navigation timeout — a page that won't load fails its own lease, never blocks a slot. */
|
|
34
39
|
navTimeoutMs?: number;
|
|
40
|
+
/**
|
|
41
|
+
* The zero-install reader, built where the daemon knows its own port and pairing token. A thunk so
|
|
42
|
+
* the ~450KB build is only read if some page ever needs it; undefined when there is none to give.
|
|
43
|
+
*/
|
|
44
|
+
zeroInstallScript?: () => string | undefined;
|
|
35
45
|
}
|
|
36
46
|
/**
|
|
37
47
|
* Owns a single browser and leases isolated contexts out of it, capped at `maxContexts`.
|
|
@@ -30,6 +30,7 @@ export class BrowserPool {
|
|
|
30
30
|
#genId;
|
|
31
31
|
#now;
|
|
32
32
|
#ttl;
|
|
33
|
+
#zeroInstall;
|
|
33
34
|
#navTimeout;
|
|
34
35
|
#browser;
|
|
35
36
|
#launching;
|
|
@@ -70,6 +71,7 @@ export class BrowserPool {
|
|
|
70
71
|
this.#now = opts.now ?? (() => Date.now());
|
|
71
72
|
this.#ttl = opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
72
73
|
this.#navTimeout = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT_MS;
|
|
74
|
+
this.#zeroInstall = opts.zeroInstallScript;
|
|
73
75
|
}
|
|
74
76
|
/** The TTL configured for leases — how long an untouched lease lives before the reaper reclaims it. */
|
|
75
77
|
leaseTtlMs() {
|
|
@@ -405,6 +407,14 @@ export class BrowserPool {
|
|
|
405
407
|
url,
|
|
406
408
|
...(navStatus !== undefined ? { navStatus } : {}),
|
|
407
409
|
release: () => this.#release(sessionId),
|
|
410
|
+
injectReader: async () => {
|
|
411
|
+
const script = this.#zeroInstall?.();
|
|
412
|
+
if (script === undefined || page.evaluate === undefined)
|
|
413
|
+
return false;
|
|
414
|
+
await page.addInitScript?.(script);
|
|
415
|
+
await page.evaluate(script);
|
|
416
|
+
return true;
|
|
417
|
+
},
|
|
408
418
|
};
|
|
409
419
|
}
|
|
410
420
|
catch (err) {
|
|
@@ -41,6 +41,7 @@ function wrapBrowser(browser) {
|
|
|
41
41
|
return {
|
|
42
42
|
goto: (url, opts) => page.goto(url, gotoOptions(opts?.timeoutMs)),
|
|
43
43
|
close: () => page.close(),
|
|
44
|
+
evaluate: (script) => page.evaluate(script),
|
|
44
45
|
// Playwright returns a Buffer; Uint8Array is what the visual store and differ take.
|
|
45
46
|
screenshot: async (opts) => new Uint8Array(await page.screenshot({ fullPage: true === opts?.fullPage })),
|
|
46
47
|
// Three moves, same as performGesture: a single move to the center can be a no-op if
|
|
@@ -34,6 +34,8 @@ export interface PooledPage {
|
|
|
34
34
|
* reports dispatched/settled while the styles never ran.
|
|
35
35
|
*/
|
|
36
36
|
hover?(x: number, y: number): Promise<void>;
|
|
37
|
+
/** Evaluate a script in the page's current document. OPTIONAL: without it no reader is injected. */
|
|
38
|
+
evaluate?(script: string): Promise<unknown>;
|
|
37
39
|
/** Add an init script to run after document creation but before any page scripts run. */
|
|
38
40
|
addInitScript?<Arg>(script: ((arg: Arg) => void) | string, arg?: Arg): Promise<InitScriptHandle | void>;
|
|
39
41
|
/**
|
|
@@ -2,7 +2,7 @@ import { type SeedStorage } from '@reticlehq/core';
|
|
|
2
2
|
import type { InitScriptHandle, PooledContext, PooledCookie, PooledPage } from './pool-contract.js';
|
|
3
3
|
export declare function targetOriginOf(navUrl: string): string | undefined;
|
|
4
4
|
export declare function normalizeCookies(cookies: SeedStorage['cookies'], navUrl: string): PooledCookie[];
|
|
5
|
-
|
|
5
|
+
interface StorageSeedResult {
|
|
6
6
|
handle?: InitScriptHandle;
|
|
7
7
|
checkError?: () => void;
|
|
8
8
|
}
|
|
@@ -21,3 +21,4 @@ export interface StorageSeedResult {
|
|
|
21
21
|
* has resolved, ensuring one-time semantics (reloads or future navigations do not reapply the seed).
|
|
22
22
|
*/
|
|
23
23
|
export declare function seedStorageInto(context: PooledContext, page: PooledPage, targetUrl: string, seed: SeedStorage): Promise<StorageSeedResult | void>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface InjectedConnect {
|
|
2
|
+
url: string;
|
|
3
|
+
/** Absent when this daemon runs without a pairing token; the bridge then asks for none. */
|
|
4
|
+
token?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The connect options a page is given when WE supply the connection rather than the app.
|
|
8
|
+
*
|
|
9
|
+
* One builder for both injection paths — this one, and re-pointing an SDK a hosted preview already
|
|
10
|
+
* ships (`real-input.ts`) — so the two cannot come to disagree about what a daemon-driven page needs.
|
|
11
|
+
*/
|
|
12
|
+
export declare function injectedConnectArgs(opts: InjectedConnect): Record<string, unknown>;
|
|
13
|
+
/**
|
|
14
|
+
* The script for a page: install the SDK if the page has none, then connect it to this daemon.
|
|
15
|
+
*
|
|
16
|
+
* The guard is on the SINGLETON, not on a timer: a page that loaded its own SDK already holds
|
|
17
|
+
* `__reticleInstance`, and evaluating a second copy would register a second session for one tab.
|
|
18
|
+
* `connect` is a no-op once connected, so calling it on the page's own instance is safe.
|
|
19
|
+
*/
|
|
20
|
+
export declare function zeroInstallScript(bundle: string, opts: InjectedConnect): string;
|
|
21
|
+
/**
|
|
22
|
+
* The single-file build, or undefined when this install has none.
|
|
23
|
+
*
|
|
24
|
+
* Read once and kept: it is ~450KB and identical for every lease. Absent means an older browser
|
|
25
|
+
* package or a broken install, and the caller then behaves exactly as it did before this existed.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readReaderBundle(): string | undefined;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The zero-install reader: how a page whose app never ran `init` still reaches a verdict.
|
|
3
|
+
*
|
|
4
|
+
* `@reticlehq/browser` builds itself into one file (`dist/reticle-inject.js`). The daemon reads that
|
|
5
|
+
* file as TEXT and evaluates it in a leased page that never connected, so the server never imports DOM
|
|
6
|
+
* code and the page needs no build step. What comes back is an ordinary session that reports no
|
|
7
|
+
* framework adapter and no source mapping, which is the honest description of what it can see.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
/** The subpath `@reticlehq/browser` exports its single-file build under. */
|
|
12
|
+
const READER_SPECIFIER = '@reticlehq/browser/inject';
|
|
13
|
+
/**
|
|
14
|
+
* The connect options a page is given when WE supply the connection rather than the app.
|
|
15
|
+
*
|
|
16
|
+
* One builder for both injection paths — this one, and re-pointing an SDK a hosted preview already
|
|
17
|
+
* ships (`real-input.ts`) — so the two cannot come to disagree about what a daemon-driven page needs.
|
|
18
|
+
*/
|
|
19
|
+
export function injectedConnectArgs(opts) {
|
|
20
|
+
return {
|
|
21
|
+
allowNonLocalhost: true,
|
|
22
|
+
...(opts.token === undefined ? {} : { token: opts.token }),
|
|
23
|
+
url: opts.url,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The script for a page: install the SDK if the page has none, then connect it to this daemon.
|
|
28
|
+
*
|
|
29
|
+
* The guard is on the SINGLETON, not on a timer: a page that loaded its own SDK already holds
|
|
30
|
+
* `__reticleInstance`, and evaluating a second copy would register a second session for one tab.
|
|
31
|
+
* `connect` is a no-op once connected, so calling it on the page's own instance is safe.
|
|
32
|
+
*/
|
|
33
|
+
export function zeroInstallScript(bundle, opts) {
|
|
34
|
+
const args = JSON.stringify(injectedConnectArgs(opts));
|
|
35
|
+
return `(() => {\nif (!globalThis.__reticleInstance) {\n${bundle}\n}\nglobalThis.__reticleInstance.connect(${args});\n})();`;
|
|
36
|
+
}
|
|
37
|
+
let cached;
|
|
38
|
+
/**
|
|
39
|
+
* The single-file build, or undefined when this install has none.
|
|
40
|
+
*
|
|
41
|
+
* Read once and kept: it is ~450KB and identical for every lease. Absent means an older browser
|
|
42
|
+
* package or a broken install, and the caller then behaves exactly as it did before this existed.
|
|
43
|
+
*/
|
|
44
|
+
export function readReaderBundle() {
|
|
45
|
+
if (cached === undefined) {
|
|
46
|
+
try {
|
|
47
|
+
cached = readFileSync(createRequire(import.meta.url).resolve(READER_SPECIFIER), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
cached = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return cached ?? undefined;
|
|
54
|
+
}
|
|
@@ -93,6 +93,11 @@ export const CHANNEL_OF_PREFIX = {
|
|
|
93
93
|
// channel at all.
|
|
94
94
|
anim: ProtocolChannel.UI,
|
|
95
95
|
dialog: ProtocolChannel.UI,
|
|
96
|
+
// A form field's value is on the subject's surface in exactly the sense `ui` means: it is what
|
|
97
|
+
// the person is looking at and typing into. The protocol has no `input` channel and does not
|
|
98
|
+
// want one — a field is a rendered thing, and the observation carries the field's NAME and
|
|
99
|
+
// length, never its contents for a password or a card.
|
|
100
|
+
field: ProtocolChannel.UI,
|
|
96
101
|
focus: ProtocolChannel.UI,
|
|
97
102
|
render: ProtocolChannel.UI,
|
|
98
103
|
reveal: ProtocolChannel.UI,
|
|
@@ -124,6 +129,7 @@ export const NOT_EVIDENCE = {
|
|
|
124
129
|
context: "Reticle's own context tool opening, not something the application did",
|
|
125
130
|
flow: 'a flow being recorded by the tool',
|
|
126
131
|
human: 'a person driving the HUD: pause, resume, a mark. The operator, not the subject',
|
|
132
|
+
hud: "usage telemetry about Reticle's own panel, taken off before the buffer. The tool, not the subject",
|
|
127
133
|
page: "the SDK's own health report about the page it is inside",
|
|
128
134
|
sdk: 'the SDK itself failing, which is a fact about the observer',
|
|
129
135
|
transport: "the tool's own buffer overflowing",
|
|
@@ -25,7 +25,7 @@ export declare const PortState: {
|
|
|
25
25
|
/** Answered with a document: a dev server serving an app. */
|
|
26
26
|
readonly SERVES_DOCUMENT: "serves-document";
|
|
27
27
|
};
|
|
28
|
-
|
|
28
|
+
type PortState = (typeof PortState)[keyof typeof PortState];
|
|
29
29
|
/** The probe's answer for one port, however it was obtained. */
|
|
30
30
|
export declare function classifyPort(port: number, probe: (port: number) => Promise<PortState>): Promise<PortState>;
|
|
31
31
|
/**
|
|
@@ -71,3 +71,4 @@ export declare function probeDevServerStates(ports?: readonly number[], probe?:
|
|
|
71
71
|
}>;
|
|
72
72
|
/** Every candidate port serving a document, in ascending order. Never rejects. */
|
|
73
73
|
export declare function probeDevServers(ports?: readonly number[], probe?: (port: number) => Promise<boolean>): Promise<number[]>;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document a local dev server actually serves: its CSP header and HTML, read once and failing
|
|
3
|
+
* soft. Shared by `doctor` (is a policy blocking the bridge?) and a lease that never dialled (was
|
|
4
|
+
* it the page's own policy?), which is why it lives beside the dev-server probe and not in either.
|
|
5
|
+
*/
|
|
6
|
+
/** What a served page said about its policy. The same shape `diagnoseObservedWebCsp` reads. */
|
|
7
|
+
export interface ObservedWebDocument {
|
|
8
|
+
url: string;
|
|
9
|
+
headers: string[];
|
|
10
|
+
html: string;
|
|
11
|
+
}
|
|
12
|
+
export type PageRequest = (url: string, init: RequestInit) => Promise<Response>;
|
|
13
|
+
interface ConnectedDocument {
|
|
14
|
+
url: string;
|
|
15
|
+
projectId?: string;
|
|
16
|
+
}
|
|
17
|
+
/** A connected tab for this project, or on its observed origin, disproves a CSP diagnosis. */
|
|
18
|
+
export declare function hasConnectedDocument(sessions: readonly ConnectedDocument[], documentUrls: readonly string[], projectId?: string): boolean;
|
|
19
|
+
/** Read the document a live local dev server actually sends, failing soft like the rest of doctor. */
|
|
20
|
+
export declare function observeWebDocument(rawUrl: string, request?: PageRequest): Promise<ObservedWebDocument | undefined>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const CSP_HEADER = 'content-security-policy';
|
|
2
|
+
const DOCUMENT_TIMEOUT_MS = 1_000;
|
|
3
|
+
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
|
|
4
|
+
const requestPage = (url, init) => fetch(url, init);
|
|
5
|
+
function loopbackDocumentUrl(raw) {
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(raw);
|
|
8
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
9
|
+
return undefined;
|
|
10
|
+
return LOOPBACK_HOSTS.includes(url.hostname) ? url : undefined;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function originOf(raw) {
|
|
17
|
+
try {
|
|
18
|
+
return new URL(raw).origin;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** A connected tab for this project, or on its observed origin, disproves a CSP diagnosis. */
|
|
25
|
+
export function hasConnectedDocument(sessions, documentUrls, projectId) {
|
|
26
|
+
if (projectId !== undefined && sessions.some((session) => session.projectId === projectId)) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
const origins = new Set(documentUrls.map(originOf).filter((value) => value !== undefined));
|
|
30
|
+
return sessions.some((session) => {
|
|
31
|
+
const origin = originOf(session.url);
|
|
32
|
+
return undefined !== origin && origins.has(origin);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Read the document a live local dev server actually sends, failing soft like the rest of doctor. */
|
|
36
|
+
export async function observeWebDocument(rawUrl, request = requestPage) {
|
|
37
|
+
const url = loopbackDocumentUrl(rawUrl);
|
|
38
|
+
if (url === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
try {
|
|
41
|
+
const response = await request(url.href, {
|
|
42
|
+
redirect: 'error',
|
|
43
|
+
signal: AbortSignal.timeout(DOCUMENT_TIMEOUT_MS),
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
return undefined;
|
|
47
|
+
const policy = response.headers.get(CSP_HEADER);
|
|
48
|
+
return {
|
|
49
|
+
url: url.href,
|
|
50
|
+
headers: null === policy ? [] : [policy],
|
|
51
|
+
html: await response.text(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read `background` out of an already-parsed `.reticle.json`: the same-origin endpoints the app calls
|
|
3
|
+
* on its own (telemetry, heartbeats), which must not hold a settle window open or contradict a claim.
|
|
4
|
+
*
|
|
5
|
+
* Takes the record rather than a path, like `readRetainPolicy`, because the config search belongs to
|
|
6
|
+
* the startup code that already does it once. Never throws, and drops a bad entry rather than the
|
|
7
|
+
* whole list.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readBackgroundTraffic(config: Record<string, unknown> | undefined): string[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The key a project writes in `.reticle.json`. */
|
|
2
|
+
const BACKGROUND_KEY = 'background';
|
|
3
|
+
/**
|
|
4
|
+
* Shorter than this names no endpoint. `/` and `//` are refused on this rule, and so is anything a
|
|
5
|
+
* mistype could leave behind.
|
|
6
|
+
*
|
|
7
|
+
* ponytail: a length floor, not a proof. `/api` passes and would still exclude the whole backend;
|
|
8
|
+
* the entries a verdict ignored are reported on it, which is what keeps a too-broad pattern visible.
|
|
9
|
+
*/
|
|
10
|
+
const MIN_PATTERN_LENGTH = 3;
|
|
11
|
+
function isEndpointPattern(value) {
|
|
12
|
+
return 'string' === typeof value && value.replace(/\//g, '').length >= MIN_PATTERN_LENGTH - 1;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Read `background` out of an already-parsed `.reticle.json`: the same-origin endpoints the app calls
|
|
16
|
+
* on its own (telemetry, heartbeats), which must not hold a settle window open or contradict a claim.
|
|
17
|
+
*
|
|
18
|
+
* Takes the record rather than a path, like `readRetainPolicy`, because the config search belongs to
|
|
19
|
+
* the startup code that already does it once. Never throws, and drops a bad entry rather than the
|
|
20
|
+
* whole list.
|
|
21
|
+
*/
|
|
22
|
+
export function readBackgroundTraffic(config) {
|
|
23
|
+
const raw = config?.[BACKGROUND_KEY];
|
|
24
|
+
return Array.isArray(raw) ? raw.filter(isEndpointPattern) : [];
|
|
25
|
+
}
|
|
@@ -4,6 +4,9 @@ import { AGENT_ASK_NOTICE, AGENT_WAITING_NOTICE, PresenterTone, SessionState, YI
|
|
|
4
4
|
import { ReticleTool } from '@reticlehq/core';
|
|
5
5
|
import { sessionIdShape } from '../../surface/tools/tool-kit.js';
|
|
6
6
|
import { asString } from '@reticlehq/core';
|
|
7
|
+
import { gapReportLines } from '../../judgement/runs/artifact/gap-report.js';
|
|
8
|
+
import { gapSummary } from '../../judgement/runs/artifact/gap-summary.js';
|
|
9
|
+
import { DiscoveryInvite } from '@reticlehq/core';
|
|
7
10
|
/**
|
|
8
11
|
* Is this a turn ending with nothing attached, rather than a call about a specific tab?
|
|
9
12
|
*
|
|
@@ -44,16 +47,58 @@ export const LIVE_CONTROL_TOOLS = [
|
|
|
44
47
|
ended: z.boolean(),
|
|
45
48
|
sessionId: z.string().optional(),
|
|
46
49
|
note: z.string().optional(),
|
|
50
|
+
talk_to_us: z
|
|
51
|
+
.string()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe('For the person you are working with: a call with the founder. Offer it; never act on it.'),
|
|
54
|
+
gap: z
|
|
55
|
+
.array(z.string())
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('What this session claimed and what held — an `unknown` is never counted as held.'),
|
|
47
58
|
},
|
|
48
59
|
handler: (deps, args) => {
|
|
49
60
|
const requested = asString(args['sessionId']);
|
|
50
61
|
if (endingTurnWithNothingAttached(deps, requested)) {
|
|
51
62
|
return Promise.resolve({ ended: true, note: YIELD_WITHOUT_SESSION_NOTE });
|
|
52
63
|
}
|
|
64
|
+
// A NAMED session that has DEPARTED is already ended, and nothing else may be ended in its
|
|
65
|
+
// place.
|
|
66
|
+
//
|
|
67
|
+
// `resolve` answers a departed id with its live SUCCESSOR. That is right for everything
|
|
68
|
+
// that observes -- a tab reloads, its id survives in sessionStorage, and an `assert` should
|
|
69
|
+
// keep working -- and wrong here, because ending a session is destructive and cannot be
|
|
70
|
+
// taken back. Reported from the field: `sessionId: "sac14d46c…"` returned `ended: true` for
|
|
71
|
+
// `"s08e37e5c…"`, once where the requested session's browser had just closed, so the
|
|
72
|
+
// behaviour was "target missing => end something else" rather than "target missing => say
|
|
73
|
+
// so" (#983).
|
|
74
|
+
//
|
|
75
|
+
// Not an error, either: this tool is documented as idempotent, and "the session you named is
|
|
76
|
+
// already gone" is precisely the idempotent answer. An id the daemon has never seen IS a
|
|
77
|
+
// mistake about which tab, and falls through to `resolve`, which refuses it with the live
|
|
78
|
+
// ids named.
|
|
79
|
+
if (requested !== undefined &&
|
|
80
|
+
deps.sessions.get(requested) === undefined &&
|
|
81
|
+
deps.sessions.departed(requested) !== undefined) {
|
|
82
|
+
return Promise.resolve({
|
|
83
|
+
ended: true,
|
|
84
|
+
sessionId: requested,
|
|
85
|
+
note: `session '${requested}' had already disconnected, so there was nothing to end. ` +
|
|
86
|
+
'Nothing else was ended: a named session is matched exactly, never substituted.',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// Resolved synchronously, as before: a named session that does not exist still throws here
|
|
90
|
+
// rather than becoming a rejected promise the caller did not expect.
|
|
53
91
|
const session = deps.sessions.resolve(requested);
|
|
54
|
-
|
|
55
|
-
session.
|
|
56
|
-
|
|
92
|
+
const summary = asString(args['summary']);
|
|
93
|
+
return session.readJournalActions().then((actions) => {
|
|
94
|
+
// The moment the agent calls the task complete is when "and what did not hold" is worth a
|
|
95
|
+
// line: the same fold `reticle_context` and `reticle report` serve, so they cannot disagree.
|
|
96
|
+
const gap = gapReportLines(gapSummary(actions));
|
|
97
|
+
const panel = [summary, gap[0]].filter((line) => line !== undefined);
|
|
98
|
+
// One PRESENTER push for the transition; the summary and the gap headline ride together.
|
|
99
|
+
session.setState(SessionState.ENDED, 0 === panel.length ? undefined : panel.join('\n'));
|
|
100
|
+
return { ended: true, sessionId: session.id, gap, talk_to_us: DiscoveryInvite.AGENT };
|
|
101
|
+
});
|
|
57
102
|
},
|
|
58
103
|
},
|
|
59
104
|
{
|