@reticlehq/server 3.1.0 → 3.2.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 +17 -18
- package/dist/command/cli/auth/device-flow.d.ts +2 -2
- package/dist/command/cli/cli-doctor.js +13 -1
- package/dist/command/cli/cli-flow-commands.d.ts +2 -1
- package/dist/command/cli/cli-kill.js +1 -1
- package/dist/command/cli/cli-parse.d.ts +2 -1
- package/dist/command/cli/cli-parse.js +5 -2
- package/dist/command/cli/cli-verify.js +2 -2
- package/dist/command/cli/cloud-cli.js +7 -6
- package/dist/command/cli/cloud-kit.js +2 -6
- package/dist/command/cli/config/config-discovery.d.ts +12 -0
- package/dist/command/cli/config/config-discovery.js +19 -0
- package/dist/command/cli/doctor/doctor-rows.d.ts +12 -0
- package/dist/command/cli/doctor/doctor-rows.js +12 -0
- package/dist/command/cli/drive/drive-command.js +1 -1
- package/dist/command/cli/launch/cli-launch.d.ts +10 -2
- package/dist/command/cli/launch/cli-launch.js +32 -39
- package/dist/command/cli/lifecycle/daemon-lifecycle.d.ts +33 -0
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +198 -0
- package/dist/command/cli/mcp-command.js +5 -3
- package/dist/command/cli/ports/resolve/cli-port.d.ts +2 -1
- package/dist/command/cli/ports/resolve/cli-port.js +5 -1
- package/dist/command/cli/status/status-lines.d.ts +28 -0
- package/dist/command/cli/status/status-lines.js +117 -0
- package/dist/command/cli.d.ts +1 -1
- package/dist/command/cli.js +24 -177
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
- package/dist/command/daemon/binding/daemon-status-probe.js +48 -0
- package/dist/command/daemon/binding/loopback-reach.d.ts +42 -0
- package/dist/command/daemon/binding/loopback-reach.js +63 -0
- package/dist/command/daemon/daemon-resilience.d.ts +13 -1
- package/dist/command/daemon/daemon-resilience.js +12 -1
- package/dist/command/daemon/daemon.d.ts +2 -11
- package/dist/command/daemon/daemon.js +8 -11
- package/dist/command/daemon/lifetime/heartbeat.d.ts +8 -0
- package/dist/command/daemon/lifetime/heartbeat.js +10 -0
- package/dist/command/daemon/lifetime/log-cap.d.ts +68 -0
- package/dist/command/daemon/lifetime/log-cap.js +115 -0
- package/dist/command/setup/agent-notice-memory.d.ts +16 -0
- package/dist/command/setup/agent-notice-memory.js +65 -0
- package/dist/command/setup/bringup/dev-server-wait.d.ts +1 -0
- package/dist/command/setup/bringup/dev-server-wait.js +16 -0
- package/dist/command/setup/bringup/ensure-daemon.js +1 -1
- package/dist/command/setup/init/init-runtime.js +13 -1
- package/dist/command/setup/node-effects.js +1 -1
- package/dist/command/setup/probe/page-probe.js +9 -2
- package/dist/command/setup/run-setup.d.ts +25 -7
- package/dist/command/setup/run-setup.js +51 -9
- package/dist/command/setup/setup-command.js +31 -3
- package/dist/command/setup/terminal/confirm.js +2 -1
- package/dist/command/update/updater.d.ts +27 -0
- package/dist/command/update/updater.js +35 -2
- package/dist/features/crawl/crawl-tools.js +3 -1
- package/dist/features/crawl/crawl.js +2 -1
- package/dist/features/harness/drive-report.d.ts +73 -0
- package/dist/features/harness/drive-report.js +196 -0
- package/dist/features/harness/driver.js +29 -8
- package/dist/features/harness/drivers.d.ts +19 -0
- package/dist/features/harness/drivers.js +19 -0
- package/dist/features/harness/fill-values.d.ts +67 -0
- package/dist/features/harness/fill-values.js +153 -0
- package/dist/features/harness/jev-driver.d.ts +188 -0
- package/dist/features/harness/jev-driver.js +664 -0
- package/dist/features/harness/openai-driver.d.ts +46 -0
- package/dist/features/harness/openai-driver.js +197 -0
- package/dist/features/harness/platform-config.d.ts +52 -0
- package/dist/features/harness/platform-config.js +89 -0
- package/dist/features/visual/visual-tools.js +26 -2
- package/dist/index.js +60 -55
- package/dist/judgement/domain/domain-tools.js +4 -2
- package/dist/judgement/runs/verification-sync.d.ts +2 -2
- package/dist/judgement/runs/verification-sync.js +9 -2
- package/dist/language/flows/affected-tools.js +6 -3
- package/dist/language/flows/flow-replay-run.d.ts +25 -2
- package/dist/language/flows/flow-replay-run.js +73 -27
- package/dist/language/flows/flow-replay.d.ts +17 -0
- package/dist/language/flows/flow-replay.js +148 -100
- package/dist/language/flows/flow-result.d.ts +18 -1
- package/dist/language/flows/flow-result.js +18 -2
- package/dist/language/flows/flow-store-for-session.d.ts +2 -1
- package/dist/language/flows/flow-tools.js +8 -4
- package/dist/language/flows/flows.d.ts +8 -7
- package/dist/language/flows/flows.js +7 -3
- package/dist/language/flows/narrow-write.d.ts +4 -4
- package/dist/language/flows/replay.js +1 -1
- package/dist/language/flows/suite-selection.d.ts +15 -1
- package/dist/language/flows/suite-selection.js +1 -1
- package/dist/language/flows/verify-change-tools.js +7 -4
- package/dist/memory/cloud/account-state.d.ts +1 -1
- package/dist/memory/cloud/account-state.js +5 -4
- package/dist/memory/cloud/cloud-config.d.ts +9 -0
- package/dist/memory/cloud/cloud-config.js +9 -0
- package/dist/memory/cloud/cloud-sync.d.ts +7 -2
- package/dist/memory/cloud/cloud-sync.js +10 -5
- package/dist/memory/cloud/harness-config.d.ts +10 -0
- package/dist/memory/cloud/harness-config.js +58 -0
- package/dist/memory/cloud/harness-offer.d.ts +44 -0
- package/dist/memory/cloud/harness-offer.js +146 -0
- package/dist/memory/cloud/harness-switch.d.ts +11 -0
- package/dist/memory/cloud/harness-switch.js +53 -0
- package/dist/memory/cloud/sync-daemon.d.ts +22 -2
- package/dist/memory/cloud/sync-daemon.js +24 -0
- package/dist/memory/impact/impact-recorder.d.ts +8 -0
- package/dist/memory/impact/impact-recorder.js +2 -1
- package/dist/memory/impact/impact-store.d.ts +5 -0
- package/dist/memory/impact/impact-store.js +19 -0
- package/dist/memory/intent/intent-shard-store.js +2 -2
- package/dist/memory/intent/intent-shard.d.ts +12 -12
- package/dist/memory/journal/attach-journal.d.ts +11 -0
- package/dist/memory/journal/attach-journal.js +1 -1
- package/dist/memory/journal/journal-query.d.ts +35 -1
- package/dist/memory/journal/journal-query.js +46 -0
- package/dist/memory/journal/journal-recorder.d.ts +50 -2
- package/dist/memory/journal/journal-recorder.js +45 -7
- package/dist/memory/journal/on-disk/retention.d.ts +87 -2
- package/dist/memory/journal/on-disk/retention.js +177 -8
- package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +9 -0
- package/dist/memory/journal/on-disk/workspace-gitignore.js +9 -2
- package/dist/memory/journal/self-observation.d.ts +3 -0
- package/dist/memory/journal/self-observation.js +67 -0
- package/dist/memory/journal/session-end.d.ts +13 -3
- package/dist/memory/journal/session-end.js +24 -4
- package/dist/memory/journal/session-journal.d.ts +115 -5
- package/dist/memory/journal/session-journal.js +479 -32
- package/dist/memory/project/artifact-root-resolver.d.ts +3 -0
- package/dist/memory/project/artifact-root-resolver.js +89 -0
- package/dist/memory/project/artifact-root.d.ts +39 -2
- package/dist/memory/project/artifact-root.js +58 -1
- package/dist/memory/project/dir/fill-value-store.d.ts +32 -0
- package/dist/memory/project/dir/fill-value-store.js +88 -0
- package/dist/memory/project/dir/reticle-dir.d.ts +14 -4
- package/dist/memory/project/dir/reticle-dir.js +11 -0
- package/dist/memory/project/fs/fs-port.d.ts +22 -1
- package/dist/memory/project/fs/fs-port.js +33 -5
- package/dist/memory/project/learned-routes.d.ts +16 -2
- package/dist/memory/project/learned-routes.js +26 -10
- package/dist/memory/project/project-for-root.d.ts +30 -0
- package/dist/memory/project/project-for-root.js +43 -0
- package/dist/memory/project/project-tools.js +10 -5
- package/dist/memory/project/session-root.d.ts +35 -6
- package/dist/memory/project/session-root.js +70 -8
- package/dist/portal/bridge/auth-failure-reason.d.ts +9 -0
- package/dist/portal/bridge/auth-failure-reason.js +15 -1
- package/dist/portal/bridge/bridge.d.ts +6 -1
- package/dist/portal/bridge/bridge.js +36 -2
- package/dist/portal/session/facts/session-replaced.d.ts +19 -0
- package/dist/portal/session/facts/session-replaced.js +22 -0
- package/dist/portal/session/no-session-diagnosis.d.ts +1 -0
- package/dist/portal/session/no-session-diagnosis.js +67 -60
- package/dist/portal/session/no-session-next-action.js +3 -2
- package/dist/portal/session/no-session-watch.js +14 -10
- package/dist/portal/session/rebind.d.ts +18 -0
- package/dist/portal/session/rebind.js +38 -0
- package/dist/portal/session/session-manager.d.ts +4 -0
- package/dist/portal/session/session-manager.js +19 -1
- package/dist/portal/session/session.d.ts +21 -10
- package/dist/portal/session/session.js +29 -42
- package/dist/status-payload.d.ts +12 -1
- package/dist/status-payload.js +4 -1
- package/dist/surface/http-server.js +3 -5
- package/dist/surface/mcp/faults/mcp-outage.d.ts +2 -1
- package/dist/surface/mcp/faults/mcp-outage.js +20 -4
- package/dist/surface/mcp/mcp-proxy.d.ts +10 -1
- package/dist/surface/mcp/mcp-proxy.js +20 -9
- package/dist/surface/mcp/mcp.d.ts +10 -1
- package/dist/surface/mcp/mcp.js +59 -29
- package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +15 -2
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +22 -5
- package/dist/surface/mcp/proxy/proxy-log.d.ts +5 -11
- package/dist/surface/mcp/proxy/proxy-log.js +7 -22
- package/dist/surface/tools/act/act-preflight.js +6 -1
- package/dist/surface/tools/act/sequence-grade.js +13 -0
- package/dist/surface/tools/act-capsule.js +27 -1
- package/dist/surface/tools/assert/assert-verdict.js +44 -17
- package/dist/surface/tools/coverage-tools.js +5 -2
- package/dist/surface/tools/dynamic-tools.js +37 -46
- package/dist/surface/tools/error-recovery.d.ts +6 -0
- package/dist/surface/tools/error-recovery.js +15 -0
- package/dist/surface/tools/explore-tools.js +80 -7
- package/dist/surface/tools/feedback-tools.js +49 -2
- package/dist/surface/tools/first-sentence.d.ts +24 -0
- package/dist/surface/tools/first-sentence.js +0 -0
- package/dist/surface/tools/harness-explore.d.ts +125 -1
- package/dist/surface/tools/harness-explore.js +401 -15
- package/dist/surface/tools/harness-plan.d.ts +76 -0
- package/dist/surface/tools/harness-plan.js +156 -0
- package/dist/surface/tools/harness-toolset.js +12 -1
- package/dist/surface/tools/invoke-tool.js +65 -5
- package/dist/surface/tools/live-call-text.d.ts +6 -0
- package/dist/surface/tools/live-call-text.js +36 -2
- package/dist/surface/tools/observe-tools.js +55 -9
- package/dist/surface/tools/read/query-windowed.d.ts +1 -1
- package/dist/surface/tools/read/query-windowed.js +1 -1
- package/dist/surface/tools/read-tools.js +1 -2
- package/dist/surface/tools/tool-kit.d.ts +18 -3
- package/dist/surface/tools/tools-helpers.d.ts +8 -7
- package/dist/surface/tools/tools-helpers.js +11 -13
- package/dist/surface/tools/tools.js +6 -1
- package/dist/telemetry/bug-found.js +32 -2
- package/dist/telemetry/feedback-context.js +3 -3
- package/dist/telemetry/harness-drive.d.ts +6 -0
- package/dist/telemetry/harness-drive.js +39 -0
- package/dist/telemetry/mcp-connection.d.ts +14 -1
- package/dist/telemetry/mcp-connection.js +17 -2
- package/dist/telemetry/onboarding-firsts.d.ts +30 -1
- package/dist/telemetry/onboarding-firsts.js +54 -12
- package/dist/telemetry/verification-of.js +31 -3
- package/docs/agent-cheatsheet.md +7 -3
- package/docs/autodrive.md +42 -2
- package/docs/benchmarks.md +1 -1
- package/docs/capabilities.mdx +1 -1
- package/docs/cli/cloud.mdx +3 -3
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/status.mdx +35 -20
- package/docs/cli.mdx +8 -18
- package/docs/desktop.mdx +1 -1
- package/docs/faq.mdx +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/predicates.mdx +16 -0
- package/docs/system-map.md +6 -4
- package/docs/telemetry.md +2 -1
- package/docs/token-efficiency.md +2 -2
- package/docs/tools/overview.mdx +1 -1
- package/docs/troubleshooting.mdx +8 -2
- package/docs/usage.md +1 -1
- package/package.json +6 -6
|
@@ -1,9 +1,35 @@
|
|
|
1
|
-
import { type JournalAction, type ReticleEvent } from '@reticlehq/core';
|
|
1
|
+
import { type JournalAction, type JournalWriteLoss, type ReticleEvent } from '@reticlehq/core';
|
|
2
2
|
/** Where a recorder persists. `SessionJournal` satisfies this structurally. */
|
|
3
3
|
export interface JournalSink {
|
|
4
4
|
appendEvents(events: readonly ReticleEvent[]): Promise<void>;
|
|
5
5
|
appendAction(action: JournalAction): Promise<void>;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* What a durable read could NOT produce — the report that travels beside the events.
|
|
9
|
+
*
|
|
10
|
+
* The journal is append-only and one session's file is never pruned, so a long-lived session's
|
|
11
|
+
* ledger outgrows what a JavaScript string can hold and the read has to stop somewhere; and what a
|
|
12
|
+
* reader keeps across many reads has to stop somewhere too. Where it stopped is not a detail: an
|
|
13
|
+
* agent reading a short answer it cannot tell apart from a complete one is the false green this
|
|
14
|
+
* whole layer exists to prevent. Absent means nothing was lost.
|
|
15
|
+
*/
|
|
16
|
+
export interface JournalReadLoss {
|
|
17
|
+
/**
|
|
18
|
+
* Journal bytes not represented in the answer — skipped past by a read that would have exceeded
|
|
19
|
+
* its ceiling, plus the bytes of records evicted after parsing. A floor, not a total: the record
|
|
20
|
+
* straddling a cut is discarded with them and is not counted here.
|
|
21
|
+
*/
|
|
22
|
+
droppedBytes: number;
|
|
23
|
+
/** Parsed records evicted from the retained set. Exact, unlike `droppedBytes`. */
|
|
24
|
+
droppedEvents: number;
|
|
25
|
+
/**
|
|
26
|
+
* Latest timestamp of parsed cache evictions, inclusive. Omitted after a byte cut skips
|
|
27
|
+
* unparsed records: their time range is unknown even when newer bytes survive.
|
|
28
|
+
*/
|
|
29
|
+
lostThroughT?: number;
|
|
30
|
+
/** Human-and-agent readable summary. Present so a consumer never has to compose one. */
|
|
31
|
+
note: string;
|
|
32
|
+
}
|
|
7
33
|
/** Read side of the durable journal — the fall-through source when the ring buffer has evicted. */
|
|
8
34
|
export interface JournalReader {
|
|
9
35
|
readEvents(): Promise<ReticleEvent[]>;
|
|
@@ -12,6 +38,21 @@ export interface JournalReader {
|
|
|
12
38
|
* needs events should be forced to grow a second method to satisfy the type.
|
|
13
39
|
*/
|
|
14
40
|
readActions?(): Promise<JournalAction[]>;
|
|
41
|
+
/**
|
|
42
|
+
* What the reads so far could not reach. Optional on the same reasoning as `readActions`: a reader
|
|
43
|
+
* that omits it declares no loss, which is the behaviour every partial double already had.
|
|
44
|
+
*/
|
|
45
|
+
readLoss?(): JournalReadLoss | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* What the WRITER could not put on disk: the ledger's closure report, when its byte ceiling
|
|
48
|
+
* refused a batch. `undefined` means nothing was refused.
|
|
49
|
+
*
|
|
50
|
+
* Optional for the same reason `readActions` is, and absence means NOT MEASURED rather than "no
|
|
51
|
+
* loss" — a reader that cannot answer must not be made to answer "clean". The separate question
|
|
52
|
+
* of what a bounded READ declined to hand back belongs to the reader that bounded it; this one is
|
|
53
|
+
* only ever about writes that never happened.
|
|
54
|
+
*/
|
|
55
|
+
readWriteLoss?(): Promise<JournalWriteLoss | undefined>;
|
|
15
56
|
}
|
|
16
57
|
interface JournalRecorderOptions {
|
|
17
58
|
/** Injected elapsed-ms clock (never read from Date here — the clock-injection rule). */
|
|
@@ -31,7 +72,14 @@ interface JournalRecorderOptions {
|
|
|
31
72
|
export declare class JournalRecorder {
|
|
32
73
|
#private;
|
|
33
74
|
constructor(sink: JournalSink, options: JournalRecorderOptions);
|
|
34
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Attribute (if an action is active), enqueue for journaling, and return the possibly-stamped event.
|
|
77
|
+
*
|
|
78
|
+
* Reticle fetching its own SDK is attributed and returned like anything else but never written to
|
|
79
|
+
* disk. That is a decision about what is worth KEEPING, so it is taken here, where the journal is
|
|
80
|
+
* written, and not in a reader: a reader that hid these would leave the bytes on disk and the
|
|
81
|
+
* cost unpaid, and a second reader would still find them and disagree about what happened.
|
|
82
|
+
*/
|
|
35
83
|
observe(event: ReticleEvent): ReticleEvent;
|
|
36
84
|
/** Open an attribution window. One action is active at a time (the agent drives sequentially). */
|
|
37
85
|
beginAction(actionId: string, tool: string, args: Record<string, unknown>): void;
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { EventAttribution, JOURNAL_FILE_VERSION, } from '@reticlehq/core';
|
|
2
|
+
import { isSelfObservation } from './self-observation.js';
|
|
3
|
+
import { log } from '../../log.js';
|
|
4
|
+
/**
|
|
5
|
+
* Logged the FIRST time a journal write is lost, and never again for that recorder.
|
|
6
|
+
*
|
|
7
|
+
* The swallow stays — a failed local journal write must never break a live session, and a tool call
|
|
8
|
+
* is not the place to learn the disk is full. But a loss that says nothing is how a journal wrote
|
|
9
|
+
* nothing for a whole session while the session reported healthy and reads kept answering from the
|
|
10
|
+
* in-memory cache. Once per recorder, on the existing daemon-log channel: enough to find in the
|
|
11
|
+
* daemon log when somebody asks why a journal file is short, quiet enough that a full disk does not
|
|
12
|
+
* produce one line per batch.
|
|
13
|
+
*/
|
|
14
|
+
const JOURNAL_WRITE_LOST = 'journal_write_lost';
|
|
2
15
|
const DEFAULT_FLUSH_AT = 64;
|
|
3
16
|
/**
|
|
4
17
|
* Owns action-window attribution and batched, order-preserving journaling — the durable half of the
|
|
@@ -16,12 +29,20 @@ export class JournalRecorder {
|
|
|
16
29
|
#pending = [];
|
|
17
30
|
#active;
|
|
18
31
|
#chain = Promise.resolve();
|
|
32
|
+
#lossReported = false;
|
|
19
33
|
constructor(sink, options) {
|
|
20
34
|
this.#sink = sink;
|
|
21
35
|
this.#now = options.now;
|
|
22
36
|
this.#flushAt = options.flushAt ?? DEFAULT_FLUSH_AT;
|
|
23
37
|
}
|
|
24
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Attribute (if an action is active), enqueue for journaling, and return the possibly-stamped event.
|
|
40
|
+
*
|
|
41
|
+
* Reticle fetching its own SDK is attributed and returned like anything else but never written to
|
|
42
|
+
* disk. That is a decision about what is worth KEEPING, so it is taken here, where the journal is
|
|
43
|
+
* written, and not in a reader: a reader that hid these would leave the bytes on disk and the
|
|
44
|
+
* cost unpaid, and a second reader would still find them and disagree about what happened.
|
|
45
|
+
*/
|
|
25
46
|
observe(event) {
|
|
26
47
|
let out = event;
|
|
27
48
|
const active = this.#active;
|
|
@@ -33,9 +54,13 @@ export class JournalRecorder {
|
|
|
33
54
|
active.seqTo = active.seqTo === undefined ? event.seq : Math.max(active.seqTo, event.seq);
|
|
34
55
|
}
|
|
35
56
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
// Attribution above still runs, and the return value is what the live ring buffer stores — the
|
|
58
|
+
// session keeps seeing this event. What is declined is the DURABLE copy: see isSelfObservation.
|
|
59
|
+
if (!isSelfObservation(out)) {
|
|
60
|
+
this.#pending.push(out);
|
|
61
|
+
if (this.#pending.length >= this.#flushAt)
|
|
62
|
+
this.#enqueueFlush();
|
|
63
|
+
}
|
|
39
64
|
return out;
|
|
40
65
|
}
|
|
41
66
|
/** Open an attribution window. One action is active at a time (the agent drives sequentially). */
|
|
@@ -63,7 +88,9 @@ export class JournalRecorder {
|
|
|
63
88
|
at: active.tStart,
|
|
64
89
|
};
|
|
65
90
|
this.#enqueueFlush();
|
|
66
|
-
this.#chain = this.#chain
|
|
91
|
+
this.#chain = this.#chain
|
|
92
|
+
.then(() => this.#sink.appendAction(action))
|
|
93
|
+
.catch((error) => this.#reportLoss(error));
|
|
67
94
|
}
|
|
68
95
|
/**
|
|
69
96
|
* Append one action record for a tool that PROVED something without driving the page.
|
|
@@ -98,7 +125,9 @@ export class JournalRecorder {
|
|
|
98
125
|
at,
|
|
99
126
|
};
|
|
100
127
|
this.#enqueueFlush();
|
|
101
|
-
this.#chain = this.#chain
|
|
128
|
+
this.#chain = this.#chain
|
|
129
|
+
.then(() => this.#sink.appendAction(action))
|
|
130
|
+
.catch((error) => this.#reportLoss(error));
|
|
102
131
|
}
|
|
103
132
|
/** Persist any buffered events now (call on session end). Awaits the write chain to settle. */
|
|
104
133
|
async flush() {
|
|
@@ -110,6 +139,15 @@ export class JournalRecorder {
|
|
|
110
139
|
return;
|
|
111
140
|
const batch = this.#pending;
|
|
112
141
|
this.#pending = [];
|
|
113
|
-
this.#chain = this.#chain
|
|
142
|
+
this.#chain = this.#chain
|
|
143
|
+
.then(() => this.#sink.appendEvents(batch))
|
|
144
|
+
.catch((error) => this.#reportLoss(error));
|
|
145
|
+
}
|
|
146
|
+
/** Swallow a lost write, but say so once. See JOURNAL_WRITE_LOST. */
|
|
147
|
+
#reportLoss(error) {
|
|
148
|
+
if (this.#lossReported)
|
|
149
|
+
return;
|
|
150
|
+
this.#lossReported = true;
|
|
151
|
+
log(JOURNAL_WRITE_LOST, { error: error instanceof Error ? error.message : String(error) });
|
|
114
152
|
}
|
|
115
153
|
}
|
|
@@ -11,6 +11,55 @@ export declare const DEFAULT_DIFF_RETENTION = 10;
|
|
|
11
11
|
* for the one case where delivery was refused and somebody has to file the report by hand.
|
|
12
12
|
*/
|
|
13
13
|
export declare const DEFAULT_FEEDBACK_RETENTION = 20;
|
|
14
|
+
/**
|
|
15
|
+
* The TOTAL the evidence tier of one `.reticle/` may occupy, across sessions, runs, overlay diffs
|
|
16
|
+
* and feedback copies together.
|
|
17
|
+
*
|
|
18
|
+
* Every bound above this line counts things, and a count cannot see a size: twenty session
|
|
19
|
+
* directories is twenty UNBOUNDED directories. A user's `.reticle/sessions` reached 8 GB with all
|
|
20
|
+
* three counts being honoured the whole time, because one drive against a chatty app appends
|
|
21
|
+
* response bodies and DOM text for as long as the drive lasts, and nothing was looking at the total.
|
|
22
|
+
*
|
|
23
|
+
* Why this number and not one an order of magnitude either side. An ordinary session journal is
|
|
24
|
+
* hundreds of kilobytes to a few megabytes; a long drive against an app with large JSON responses
|
|
25
|
+
* runs to tens. Twenty of the ordinary ones fit inside this budget outright, which is the working
|
|
26
|
+
* set somebody plausibly wants — the evidence a person reads is from the drive they are looking at
|
|
27
|
+
* and the two before it, and nobody has ever opened the twentieth. A tenth of this (~50 MB) is
|
|
28
|
+
* smaller than a single bad session, so the budget would evict the newest evidence while it was
|
|
29
|
+
* still being written and the count bound could never be reached; ten times it (~5 GB) is not a
|
|
30
|
+
* bound a laptop notices, and 5 GB of untouched journals is the same abandoned-Reticle report with
|
|
31
|
+
* a different number in it. This is roughly one node_modules: large enough to be invisible in a
|
|
32
|
+
* project checkout, small enough that it can never be the reason a disk fills.
|
|
33
|
+
*
|
|
34
|
+
* Both bounds stay, and they are independent on purpose. The count is the cheap guard — it needs one
|
|
35
|
+
* stat per entry and it keeps the directory listing readable. The budget is the guard that holds
|
|
36
|
+
* when the counts are all satisfied and the bytes are not, which is the only situation anybody has
|
|
37
|
+
* actually been hurt by. Either one alone leaves the other's failure unguarded.
|
|
38
|
+
*/
|
|
39
|
+
export declare const DEFAULT_EVIDENCE_BUDGET_BYTES: number;
|
|
40
|
+
/**
|
|
41
|
+
* One candidate entry inside a `.reticle/`, classified by the top-level directory it lives under.
|
|
42
|
+
*
|
|
43
|
+
* `under` is what decides the tier, and it is compared against `TRANSIENT_DIRS` — the same list the
|
|
44
|
+
* workspace gitignore writes, guarded there as an exhaustive partition of what `.reticle/` holds.
|
|
45
|
+
* Anything else is MEMORY: flows, capsules, baselines, the contract, the intent ledger. Those are
|
|
46
|
+
* small, durable and the reason the directory exists, so they are never evicted, never counted, and
|
|
47
|
+
* must keep being written however full the disk is.
|
|
48
|
+
*/
|
|
49
|
+
export interface TierEntry {
|
|
50
|
+
/** Absolute path, and what is removed. */
|
|
51
|
+
path: string;
|
|
52
|
+
/** The `.reticle/` top-level entry this lives under. */
|
|
53
|
+
under: string;
|
|
54
|
+
sizeBytes: number;
|
|
55
|
+
mtimeMs: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Pure: the paths to remove so the EVIDENCE tier fits in `budgetBytes`. Oldest first, memory-tier
|
|
59
|
+
* entries never selected and never counted. No IO — the call site stats and removes, exactly as
|
|
60
|
+
* `selectPrunable` and `pruneByRecency` already split it.
|
|
61
|
+
*/
|
|
62
|
+
export declare function selectOverBudget(entries: readonly TierEntry[], budgetBytes: number, live?: ReadonlySet<string>): string[];
|
|
14
63
|
interface DatedDir {
|
|
15
64
|
name: string;
|
|
16
65
|
mtimeMs: number;
|
|
@@ -20,13 +69,38 @@ interface DatedDir {
|
|
|
20
69
|
* remain. Oldest-first. Exported for testing (no IO).
|
|
21
70
|
*/
|
|
22
71
|
export declare function selectPrunable(dirs: readonly DatedDir[], retention: number): string[];
|
|
72
|
+
/** What a sweep is allowed to delete. Every field optional: omitting one keeps the old behaviour. */
|
|
73
|
+
export interface RetentionOptions {
|
|
74
|
+
/** How many session directories survive the count bound. */
|
|
75
|
+
retention?: number;
|
|
76
|
+
/** The TOTAL the evidence tier may occupy. */
|
|
77
|
+
budgetBytes?: number;
|
|
78
|
+
/**
|
|
79
|
+
* The ids of sessions that are STILL OPEN. Neither bound may evict one.
|
|
80
|
+
*
|
|
81
|
+
* Without this both bounds could delete the journal of the session currently being written, and
|
|
82
|
+
* the long-running session was the FIRST candidate rather than the last: a directory's mtime is
|
|
83
|
+
* stamped when it is created and never moves again — appending to a file inside it advances the
|
|
84
|
+
* file's mtime, not the directory's — so an hour-long drive looks older than every short session
|
|
85
|
+
* that started after it. The journal then latched its directory as ensured, every later append
|
|
86
|
+
* failed ENOENT into a swallowed catch, and reads kept answering from the in-memory cache, so the
|
|
87
|
+
* session reported healthy with nothing reaching disk.
|
|
88
|
+
*/
|
|
89
|
+
live?: ReadonlySet<string> | undefined;
|
|
90
|
+
}
|
|
23
91
|
/**
|
|
24
92
|
* Bound the journal on disk: keep the `retention` most-recent session directories under
|
|
25
93
|
* `.reticle/sessions/`, remove the rest by mtime. Never throws — retention is best-effort maintenance,
|
|
26
94
|
* so a stat/rm failure on one dir (or a missing sessions/ dir) is swallowed, never crashing a session.
|
|
27
|
-
* Mirrors RunStore's amortized pruning; run on daemon start.
|
|
95
|
+
* Mirrors RunStore's amortized pruning; run on daemon start and at the end of every session.
|
|
96
|
+
*
|
|
97
|
+
* An open session is excluded from BOTH bounds — see `RetentionOptions.live`.
|
|
98
|
+
*
|
|
99
|
+
* ponytail: excluded via `keep`, so an open session is not COUNTED toward the retention cap either.
|
|
100
|
+
* The directory can therefore hold `retention` + (concurrently open sessions) entries, which is a
|
|
101
|
+
* handful. Count them separately only if somebody runs enough parallel sessions for it to matter.
|
|
28
102
|
*/
|
|
29
|
-
export declare function pruneSessions(fs: FileSystemPort, root: string,
|
|
103
|
+
export declare function pruneSessions(fs: FileSystemPort, root: string, options?: RetentionOptions): Promise<void>;
|
|
30
104
|
/**
|
|
31
105
|
* Bound `.reticle/visual/` — the largest thing in the workspace, and the only one with no delete
|
|
32
106
|
* path at all.
|
|
@@ -42,6 +116,17 @@ export declare function pruneSessions(fs: FileSystemPort, root: string, retentio
|
|
|
42
116
|
* `pruneSessions`: this is maintenance, and maintenance must never be the reason a daemon fails.
|
|
43
117
|
*/
|
|
44
118
|
export declare function pruneVisualDiffs(fs: FileSystemPort, root: string, retention?: number): Promise<void>;
|
|
119
|
+
/**
|
|
120
|
+
* Hold the evidence tier of one `.reticle/` under a TOTAL byte budget, evicting oldest-first.
|
|
121
|
+
*
|
|
122
|
+
* The memory tier is not read, not counted and not touched: the maintainer's constraint is a cap
|
|
123
|
+
* that never stops memory and flows being created, so a full disk costs somebody their session
|
|
124
|
+
* journals and never their flows.
|
|
125
|
+
*
|
|
126
|
+
* Never throws, like everything else here. Retention is maintenance, and maintenance must never be
|
|
127
|
+
* the reason a daemon fails to come up or a session dies.
|
|
128
|
+
*/
|
|
129
|
+
export declare function pruneEvidenceBudget(fs: FileSystemPort, root: string, budgetBytes?: number, live?: ReadonlySet<string>): Promise<void>;
|
|
45
130
|
/**
|
|
46
131
|
* Bound `.reticle/feedback/` — local copies of reports that were already sent.
|
|
47
132
|
*
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
1
|
+
import { basename, join } from 'node:path';
|
|
2
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
2
3
|
import { reticleDirPaths, visualDir } from '../../project/dir/reticle-dir.js';
|
|
4
|
+
import { TRANSIENT_DIRS } from './workspace-gitignore.js';
|
|
3
5
|
/** Keep at most this many session journals on disk; older ones are pruned by recency. */
|
|
4
6
|
export const DEFAULT_SESSION_RETENTION = 20;
|
|
5
7
|
/**
|
|
@@ -13,6 +15,69 @@ export const DEFAULT_DIFF_RETENTION = 10;
|
|
|
13
15
|
*/
|
|
14
16
|
export const DEFAULT_FEEDBACK_RETENTION = 20;
|
|
15
17
|
const DIFF_SUFFIX = '.diff.png';
|
|
18
|
+
const PNG_SUFFIX = '.png';
|
|
19
|
+
/**
|
|
20
|
+
* The TOTAL the evidence tier of one `.reticle/` may occupy, across sessions, runs, overlay diffs
|
|
21
|
+
* and feedback copies together.
|
|
22
|
+
*
|
|
23
|
+
* Every bound above this line counts things, and a count cannot see a size: twenty session
|
|
24
|
+
* directories is twenty UNBOUNDED directories. A user's `.reticle/sessions` reached 8 GB with all
|
|
25
|
+
* three counts being honoured the whole time, because one drive against a chatty app appends
|
|
26
|
+
* response bodies and DOM text for as long as the drive lasts, and nothing was looking at the total.
|
|
27
|
+
*
|
|
28
|
+
* Why this number and not one an order of magnitude either side. An ordinary session journal is
|
|
29
|
+
* hundreds of kilobytes to a few megabytes; a long drive against an app with large JSON responses
|
|
30
|
+
* runs to tens. Twenty of the ordinary ones fit inside this budget outright, which is the working
|
|
31
|
+
* set somebody plausibly wants — the evidence a person reads is from the drive they are looking at
|
|
32
|
+
* and the two before it, and nobody has ever opened the twentieth. A tenth of this (~50 MB) is
|
|
33
|
+
* smaller than a single bad session, so the budget would evict the newest evidence while it was
|
|
34
|
+
* still being written and the count bound could never be reached; ten times it (~5 GB) is not a
|
|
35
|
+
* bound a laptop notices, and 5 GB of untouched journals is the same abandoned-Reticle report with
|
|
36
|
+
* a different number in it. This is roughly one node_modules: large enough to be invisible in a
|
|
37
|
+
* project checkout, small enough that it can never be the reason a disk fills.
|
|
38
|
+
*
|
|
39
|
+
* Both bounds stay, and they are independent on purpose. The count is the cheap guard — it needs one
|
|
40
|
+
* stat per entry and it keeps the directory listing readable. The budget is the guard that holds
|
|
41
|
+
* when the counts are all satisfied and the bytes are not, which is the only situation anybody has
|
|
42
|
+
* actually been hurt by. Either one alone leaves the other's failure unguarded.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_EVIDENCE_BUDGET_BYTES = 512 * 1024 * 1024;
|
|
45
|
+
/** No session is open. The default everywhere, so an existing caller keeps its old behaviour. */
|
|
46
|
+
const NO_LIVE_SESSIONS = new Set();
|
|
47
|
+
/**
|
|
48
|
+
* Is this entry the journal directory of a session that is STILL BEING WRITTEN?
|
|
49
|
+
*
|
|
50
|
+
* A session directory is named for its session id, so the id IS the basename. Scoped to the
|
|
51
|
+
* sessions tier on purpose: a run artifact or a diff that happens to share a session's name is not
|
|
52
|
+
* a journal, and nothing is holding it open.
|
|
53
|
+
*/
|
|
54
|
+
function isOpenSession(entry, live) {
|
|
55
|
+
return ReticleDir.SESSIONS_SUBDIR === entry.under && live.has(basename(entry.path));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Pure: the paths to remove so the EVIDENCE tier fits in `budgetBytes`. Oldest first, memory-tier
|
|
59
|
+
* entries never selected and never counted. No IO — the call site stats and removes, exactly as
|
|
60
|
+
* `selectPrunable` and `pruneByRecency` already split it.
|
|
61
|
+
*/
|
|
62
|
+
export function selectOverBudget(entries, budgetBytes, live = NO_LIVE_SESSIONS) {
|
|
63
|
+
const evidence = entries.filter((entry) => TRANSIENT_DIRS.includes(entry.under));
|
|
64
|
+
// Live sessions still COUNT — their bytes are on disk and the budget is about the disk. They are
|
|
65
|
+
// only excluded from the eviction list, so a session over budget on its own is left alone rather
|
|
66
|
+
// than deleted under its own writer.
|
|
67
|
+
let total = evidence.reduce((sum, entry) => sum + entry.sizeBytes, 0);
|
|
68
|
+
if (total <= budgetBytes)
|
|
69
|
+
return [];
|
|
70
|
+
const doomed = [];
|
|
71
|
+
for (const entry of [...evidence].sort((a, b) => a.mtimeMs - b.mtimeMs)) {
|
|
72
|
+
if (isOpenSession(entry, live))
|
|
73
|
+
continue;
|
|
74
|
+
if (total <= budgetBytes)
|
|
75
|
+
break;
|
|
76
|
+
doomed.push(entry.path);
|
|
77
|
+
total -= entry.sizeBytes;
|
|
78
|
+
}
|
|
79
|
+
return doomed;
|
|
80
|
+
}
|
|
16
81
|
/** Where refused feedback reports are copied. Named here because the writer lives in another area. */
|
|
17
82
|
const FEEDBACK_SUBDIR = 'feedback';
|
|
18
83
|
/**
|
|
@@ -71,10 +136,21 @@ async function pruneByRecency(fs, dir, retention, keep = () => true) {
|
|
|
71
136
|
* Bound the journal on disk: keep the `retention` most-recent session directories under
|
|
72
137
|
* `.reticle/sessions/`, remove the rest by mtime. Never throws — retention is best-effort maintenance,
|
|
73
138
|
* so a stat/rm failure on one dir (or a missing sessions/ dir) is swallowed, never crashing a session.
|
|
74
|
-
* Mirrors RunStore's amortized pruning; run on daemon start.
|
|
139
|
+
* Mirrors RunStore's amortized pruning; run on daemon start and at the end of every session.
|
|
140
|
+
*
|
|
141
|
+
* An open session is excluded from BOTH bounds — see `RetentionOptions.live`.
|
|
142
|
+
*
|
|
143
|
+
* ponytail: excluded via `keep`, so an open session is not COUNTED toward the retention cap either.
|
|
144
|
+
* The directory can therefore hold `retention` + (concurrently open sessions) entries, which is a
|
|
145
|
+
* handful. Count them separately only if somebody runs enough parallel sessions for it to matter.
|
|
75
146
|
*/
|
|
76
|
-
export async function pruneSessions(fs, root,
|
|
77
|
-
|
|
147
|
+
export async function pruneSessions(fs, root, options = {}) {
|
|
148
|
+
const live = options.live ?? NO_LIVE_SESSIONS;
|
|
149
|
+
const retention = options.retention ?? DEFAULT_SESSION_RETENTION;
|
|
150
|
+
await pruneByRecency(fs, reticleDirPaths(root).sessions, retention, (name) => !live.has(name));
|
|
151
|
+
// The tier total is swept from here because this is the one sweep that runs on BOTH the daemon's
|
|
152
|
+
// start path and the end of every session, and the end of a session is when the bytes arrive.
|
|
153
|
+
await pruneEvidenceBudget(fs, root, options.budgetBytes ?? DEFAULT_EVIDENCE_BUDGET_BYTES, live);
|
|
78
154
|
}
|
|
79
155
|
/**
|
|
80
156
|
* Bound `.reticle/visual/` — the largest thing in the workspace, and the only one with no delete
|
|
@@ -91,20 +167,113 @@ export async function pruneSessions(fs, root, retention = DEFAULT_SESSION_RETENT
|
|
|
91
167
|
* `pruneSessions`: this is maintenance, and maintenance must never be the reason a daemon fails.
|
|
92
168
|
*/
|
|
93
169
|
export async function pruneVisualDiffs(fs, root, retention = DEFAULT_DIFF_RETENTION) {
|
|
170
|
+
for (const dir of await visualDirs(fs, root)) {
|
|
171
|
+
await pruneByRecency(fs, dir, retention, isDiff);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** `visual/` plus its per-runtime subdirectories. Empty when nothing has been captured yet. */
|
|
175
|
+
async function visualDirs(fs, root) {
|
|
94
176
|
const top = visualDir(root);
|
|
95
177
|
const dirs = [top];
|
|
96
178
|
try {
|
|
97
179
|
// A per-runtime subdirectory is anything here that is not itself a PNG.
|
|
98
180
|
for (const entry of await fs.readdir(top)) {
|
|
99
|
-
if (!entry.endsWith(
|
|
181
|
+
if (!entry.endsWith(PNG_SUFFIX))
|
|
100
182
|
dirs.push(join(top, entry));
|
|
101
183
|
}
|
|
102
184
|
}
|
|
103
185
|
catch {
|
|
104
|
-
return;
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
return dirs;
|
|
189
|
+
}
|
|
190
|
+
const isDiff = (name) => name.endsWith(DIFF_SUFFIX);
|
|
191
|
+
/**
|
|
192
|
+
* Bytes under `path`, summed recursively. A session journal is a DIRECTORY, and `stat` on one
|
|
193
|
+
* reports the dirent, not the megabytes of JSONL inside it.
|
|
194
|
+
*
|
|
195
|
+
* readdir-first because the port exposes no `isDirectory`: a readdir that fails is a file, and its
|
|
196
|
+
* own stat answers. Never throws — an unreadable entry contributes nothing, which can only make the
|
|
197
|
+
* sweep evict less than it should, never more.
|
|
198
|
+
*/
|
|
199
|
+
async function sizeOf(fs, path) {
|
|
200
|
+
let names;
|
|
201
|
+
try {
|
|
202
|
+
names = await fs.readdir(path);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
try {
|
|
206
|
+
return (await fs.stat(path)).size;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
let total = 0;
|
|
213
|
+
for (const name of names)
|
|
214
|
+
total += await sizeOf(fs, join(path, name));
|
|
215
|
+
return total;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Every evidence entry in this `.reticle/`, sized and dated. The IO half of the byte budget.
|
|
219
|
+
*
|
|
220
|
+
* `visual/` is the one evidence directory holding something that is not evidence — the PNG
|
|
221
|
+
* BASELINES a comparison means anything against — so the same `.diff.png` filter `pruneVisualDiffs`
|
|
222
|
+
* applies is applied here. The tier list is the coarse rule; that filter is the finer one, and
|
|
223
|
+
* deleting a baseline to save disk turns the next run's verdict into `baseline-missing`.
|
|
224
|
+
*/
|
|
225
|
+
async function collectEvidence(fs, root) {
|
|
226
|
+
const entries = [];
|
|
227
|
+
for (const under of TRANSIENT_DIRS) {
|
|
228
|
+
const isVisual = ReticleDir.VISUAL_SUBDIR === under;
|
|
229
|
+
const dirs = isVisual ? await visualDirs(fs, root) : [join(root, under)];
|
|
230
|
+
for (const dir of dirs) {
|
|
231
|
+
let names;
|
|
232
|
+
try {
|
|
233
|
+
names = await fs.readdir(dir);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
continue; // nothing written here yet
|
|
237
|
+
}
|
|
238
|
+
for (const name of names) {
|
|
239
|
+
if (isVisual && !isDiff(name))
|
|
240
|
+
continue;
|
|
241
|
+
const path = join(dir, name);
|
|
242
|
+
try {
|
|
243
|
+
const { mtimeMs } = await fs.stat(path);
|
|
244
|
+
entries.push({ path, under, sizeBytes: await sizeOf(fs, path), mtimeMs });
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
/* unreadable entry — skip */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return entries;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Hold the evidence tier of one `.reticle/` under a TOTAL byte budget, evicting oldest-first.
|
|
256
|
+
*
|
|
257
|
+
* The memory tier is not read, not counted and not touched: the maintainer's constraint is a cap
|
|
258
|
+
* that never stops memory and flows being created, so a full disk costs somebody their session
|
|
259
|
+
* journals and never their flows.
|
|
260
|
+
*
|
|
261
|
+
* Never throws, like everything else here. Retention is maintenance, and maintenance must never be
|
|
262
|
+
* the reason a daemon fails to come up or a session dies.
|
|
263
|
+
*/
|
|
264
|
+
export async function pruneEvidenceBudget(fs, root, budgetBytes = DEFAULT_EVIDENCE_BUDGET_BYTES, live = NO_LIVE_SESSIONS) {
|
|
265
|
+
try {
|
|
266
|
+
for (const path of selectOverBudget(await collectEvidence(fs, root), budgetBytes, live)) {
|
|
267
|
+
try {
|
|
268
|
+
await fs.rm(path);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
/* best-effort */
|
|
272
|
+
}
|
|
273
|
+
}
|
|
105
274
|
}
|
|
106
|
-
|
|
107
|
-
|
|
275
|
+
catch {
|
|
276
|
+
/* maintenance never fails a daemon */
|
|
108
277
|
}
|
|
109
278
|
}
|
|
110
279
|
/**
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { FileSystemPort } from '../../project/fs/fs-port.js';
|
|
2
|
+
/**
|
|
3
|
+
* Local directories, without the trailing slash the ignore file wants.
|
|
4
|
+
*
|
|
5
|
+
* Exported because this is also the EVIDENCE tier: the per-session churn that retention is allowed
|
|
6
|
+
* to delete. Read from here rather than restated next to the byte budget — the partition of what
|
|
7
|
+
* `.reticle/` holds already exists, is guarded as exhaustive, and a second copy of it would be the
|
|
8
|
+
* list that silently disagrees.
|
|
9
|
+
*/
|
|
10
|
+
export declare const TRANSIENT_DIRS: readonly string[];
|
|
2
11
|
/** Every name Reticle treats as machine-local, however it is spelled in the ignore file. */
|
|
3
12
|
export declare const TRANSIENT_NAMES: readonly string[];
|
|
4
13
|
/**
|
|
@@ -20,8 +20,15 @@ import { ReticleDir } from '@reticlehq/core';
|
|
|
20
20
|
* or not that name has been hoisted yet.
|
|
21
21
|
*/
|
|
22
22
|
const FEEDBACK_SUBDIR = 'feedback';
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Local directories, without the trailing slash the ignore file wants.
|
|
25
|
+
*
|
|
26
|
+
* Exported because this is also the EVIDENCE tier: the per-session churn that retention is allowed
|
|
27
|
+
* to delete. Read from here rather than restated next to the byte budget — the partition of what
|
|
28
|
+
* `.reticle/` holds already exists, is guarded as exhaustive, and a second copy of it would be the
|
|
29
|
+
* list that silently disagrees.
|
|
30
|
+
*/
|
|
31
|
+
export const TRANSIENT_DIRS = [
|
|
25
32
|
ReticleDir.SESSIONS_SUBDIR,
|
|
26
33
|
ReticleDir.RUNS_SUBDIR,
|
|
27
34
|
ReticleDir.VISUAL_SUBDIR,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { EventType, urlForMatch } from '@reticlehq/core';
|
|
2
|
+
/**
|
|
3
|
+
* A request the page made for Reticle's OWN code, which the journal declines to keep.
|
|
4
|
+
*
|
|
5
|
+
* Measured over this repo's twenty recorded sessions: `net.request` is 64.6% of every journal byte
|
|
6
|
+
* and `net.detail` a further 17.6%, and the bulk of that is the browser fetching the SDK's module
|
|
7
|
+
* graph over and over as the dev server hot-reloads. Those bytes are Reticle watching itself arrive.
|
|
8
|
+
* They can never be evidence about the app under test, because the app did not ask for them — the
|
|
9
|
+
* instrumentation did, and it did so before the app had done anything worth verifying.
|
|
10
|
+
*
|
|
11
|
+
* The DOM side has drawn this line for a long time (`isReticleUi`), and the transport side draws it
|
|
12
|
+
* on the bridge socket's own path. This is the same line on the network channel.
|
|
13
|
+
*
|
|
14
|
+
* Identity, not resemblance. A request qualifies only when its PATH names our npm scope, which is
|
|
15
|
+
* the one thing about a URL that nobody else can be: `@reticlehq` is a registry scope we own, so a
|
|
16
|
+
* path segment under it is a file from one of our packages however it is being served — from
|
|
17
|
+
* `node_modules`, from a CDN, or through Vite's dep optimizer, which flattens the scope to
|
|
18
|
+
* `@reticlehq_browser.js`. A user's own file is never addressed that way.
|
|
19
|
+
*
|
|
20
|
+
* What this deliberately does NOT do is guess from shape. `/core/dist/index.js` and
|
|
21
|
+
* `/adapters/realm/browser/dist/index.js` are what this repo's own linked checkout serves, and
|
|
22
|
+
* matching those would collapse most of the bytes measured above — but they are ordinary paths that
|
|
23
|
+
* somebody else's app is entitled to serve, and a filter that drops a user's request to save our own
|
|
24
|
+
* bytes is the trade this project has already measured the cost of. So a source-linked checkout of
|
|
25
|
+
* Reticle itself keeps journaling its own SDK, and that is the correct side to be wrong on.
|
|
26
|
+
*
|
|
27
|
+
* Only the query string can carry a scope-shaped string that is really app data (a search for
|
|
28
|
+
* `@reticlehq/browser`), so the decision is taken on `pathname` alone.
|
|
29
|
+
*/
|
|
30
|
+
/** The npm scope directory, as it appears when the package is served from a tree. */
|
|
31
|
+
const SCOPE_PATH = '/@reticlehq/';
|
|
32
|
+
/**
|
|
33
|
+
* The same scope after Vite's dependency optimizer flattens `@scope/name` to one filename. Anchored
|
|
34
|
+
* at a segment boundary so it cannot match in the middle of somebody's filename.
|
|
35
|
+
*/
|
|
36
|
+
const SCOPE_FLATTENED = '/@reticlehq_';
|
|
37
|
+
/**
|
|
38
|
+
* Every event kind that carries a request URL — all of them, on purpose.
|
|
39
|
+
*
|
|
40
|
+
* `reconcileNet` pairs a NET_PENDING with its NET_REQUEST by id and reports an unpaired pending as a
|
|
41
|
+
* HUNG request. Dropping one half of a pair would therefore manufacture a finding out of a request
|
|
42
|
+
* that completed perfectly. The rule is a pure function of the URL, so both halves of a pair always
|
|
43
|
+
* get the same answer and no pair can be split.
|
|
44
|
+
*/
|
|
45
|
+
const NET_TYPES = new Set([
|
|
46
|
+
EventType.NET_REQUEST,
|
|
47
|
+
EventType.NET_PENDING,
|
|
48
|
+
EventType.NET_DETAIL,
|
|
49
|
+
EventType.NET_STREAM,
|
|
50
|
+
]);
|
|
51
|
+
/** A base for parsing relative URLs. Never dereferenced — only the path is read. */
|
|
52
|
+
const RELATIVE_BASE = 'http://reticle.invalid';
|
|
53
|
+
function pathOf(url) {
|
|
54
|
+
try {
|
|
55
|
+
return new URL(url, RELATIVE_BASE).pathname;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return url;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** True when this event is the page fetching Reticle's own code rather than doing anything. */
|
|
62
|
+
export function isSelfObservation(event) {
|
|
63
|
+
if (!NET_TYPES.has(event.type))
|
|
64
|
+
return false;
|
|
65
|
+
const path = pathOf(urlForMatch(event.data));
|
|
66
|
+
return path.includes(SCOPE_PATH) || path.includes(SCOPE_FLATTENED);
|
|
67
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type DriveProgram, type TapeStep } from './drive-flow.js';
|
|
2
2
|
import { type OnboardingStep } from '@reticlehq/core/telemetry';
|
|
3
3
|
import type { AmbientCounts } from '@reticlehq/engine/window/ambient.js';
|
|
4
|
-
import { type JournalAction } from '@reticlehq/core';
|
|
4
|
+
import { type ProjectId, type JournalAction } from '@reticlehq/core';
|
|
5
5
|
import type { FileSystemPort } from '../project/fs/fs-port.js';
|
|
6
6
|
/**
|
|
7
7
|
* Session teardown: the durable half of ending a session. Two things must happen when a tab disconnects,
|
|
@@ -39,7 +39,7 @@ export interface SessionEndTarget {
|
|
|
39
39
|
readJournalActions?(): Promise<JournalAction[]>;
|
|
40
40
|
/** The project's own `.reticle`, so a run lands in the repo it is about. */
|
|
41
41
|
readonly artifactRoot?: string | undefined;
|
|
42
|
-
readonly projectId?:
|
|
42
|
+
readonly projectId?: ProjectId | undefined;
|
|
43
43
|
}
|
|
44
44
|
interface SessionEndDeps {
|
|
45
45
|
fs: FileSystemPort;
|
|
@@ -76,7 +76,7 @@ interface SessionEndDeps {
|
|
|
76
76
|
* this handler follows.
|
|
77
77
|
*/
|
|
78
78
|
flows?: {
|
|
79
|
-
save: (program: DriveProgram, annotations?: undefined, projectId?:
|
|
79
|
+
save: (program: DriveProgram, annotations?: undefined, projectId?: ProjectId) => Promise<unknown>;
|
|
80
80
|
};
|
|
81
81
|
/**
|
|
82
82
|
* Called when a run artifact is written, so cloud sync can cycle instead of waiting for its timer.
|
|
@@ -84,6 +84,16 @@ interface SessionEndDeps {
|
|
|
84
84
|
* Optional, like everything else here: absent simply means the next scheduled cycle picks it up.
|
|
85
85
|
*/
|
|
86
86
|
onRunPersisted?: () => void;
|
|
87
|
+
/**
|
|
88
|
+
* The ids of the sessions still OPEN, read at each teardown so retention never deletes a journal
|
|
89
|
+
* that is still being written. A getter rather than a snapshot: this handler is built once at
|
|
90
|
+
* wiring and fires for every session for the life of the daemon.
|
|
91
|
+
*
|
|
92
|
+
* Injected, like the clock and the reporter — teardown needs the answer, not the registry.
|
|
93
|
+
* Optional so every existing construction keeps compiling, and absent means "none known", which
|
|
94
|
+
* is exactly the old behaviour.
|
|
95
|
+
*/
|
|
96
|
+
liveSessionIds?: () => ReadonlySet<string>;
|
|
87
97
|
}
|
|
88
98
|
/**
|
|
89
99
|
* Build the teardown handler the bridge fires when a session is removed. Flushes the journal first (so no
|