@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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this workspace has taken the three months of free harness, read by the thing that would
|
|
3
|
+
* advertise it.
|
|
4
|
+
*
|
|
5
|
+
* The HUD can only advertise honestly if it knows, and only the platform knows. This is the read —
|
|
6
|
+
* filed beside the other reads of the linked account rather than with the harness itself, because
|
|
7
|
+
* nothing that DRIVES needs it; the thing that needs it is the snapshot the HUD paints from. Same
|
|
8
|
+
* shape as the harness's own `platform-config.ts` and for the same reasons: it is INFORMATION, not a
|
|
9
|
+
* credential, so every failure answers `undefined`, which every caller must treat as "say nothing"
|
|
10
|
+
* rather than as "not claimed". An offline laptop must not start advertising something the person
|
|
11
|
+
* already owns.
|
|
12
|
+
*
|
|
13
|
+
* It is also SYNCHRONOUS at the point of use. The impact snapshot is built inline, dozens of times a
|
|
14
|
+
* session, and the answer changes about once per workspace lifetime, so a cache that refreshes in
|
|
15
|
+
* the background is the whole design: readers get what we last heard, and a stale answer is only
|
|
16
|
+
* ever stale in the safe direction — the card the person is looking at is the one they can act on.
|
|
17
|
+
*/
|
|
18
|
+
import { ReticleEnv, apiKeyFrom } from '@reticlehq/core';
|
|
19
|
+
const OFFER_PATH = '/v1/harness/offer';
|
|
20
|
+
/** Same budget as the model-config read: a drive must never wait on an advert. */
|
|
21
|
+
const TIMEOUT_MS = 2_000;
|
|
22
|
+
/**
|
|
23
|
+
* How long a cached answer is trusted.
|
|
24
|
+
*
|
|
25
|
+
* A claim happens once. The only transition worth catching quickly is the one the person just made
|
|
26
|
+
* in another tab, and ten minutes is well inside the time it takes them to come back and look.
|
|
27
|
+
*/
|
|
28
|
+
const FRESH_MS = 10 * 60 * 1_000;
|
|
29
|
+
/**
|
|
30
|
+
* Parse the platform's answer.
|
|
31
|
+
*
|
|
32
|
+
* The platform reports `null` for "never claimed" on the fields it has no value for; the wire
|
|
33
|
+
* contract says ABSENT. They are the same fact and the translation belongs here, at the edge,
|
|
34
|
+
* rather than in a HUD that would then need to know about two spellings of nothing.
|
|
35
|
+
*/
|
|
36
|
+
function parse(body, claimUrl) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(body);
|
|
39
|
+
if ('object' !== typeof parsed || null === parsed)
|
|
40
|
+
return undefined;
|
|
41
|
+
const record = parsed;
|
|
42
|
+
const claimed = record['claimed'];
|
|
43
|
+
if ('boolean' !== typeof claimed)
|
|
44
|
+
return undefined;
|
|
45
|
+
const offer = { claimed };
|
|
46
|
+
const expiresAt = record['expiresAt'];
|
|
47
|
+
if ('number' === typeof expiresAt)
|
|
48
|
+
offer.expiresAt = expiresAt;
|
|
49
|
+
const days = record['daysRemaining'];
|
|
50
|
+
if ('number' === typeof days)
|
|
51
|
+
offer.daysRemaining = days;
|
|
52
|
+
// The one field that cannot be inferred from the others: a LAPSED claim reports `claimed:false`
|
|
53
|
+
// and `daysRemaining:0`, which is indistinguishable from never having claimed unless the
|
|
54
|
+
// platform's own answer about offerability is carried through.
|
|
55
|
+
const eligible = record['eligible'];
|
|
56
|
+
if ('boolean' === typeof eligible)
|
|
57
|
+
offer.eligible = eligible;
|
|
58
|
+
if (claimUrl !== undefined && 0 < claimUrl.length)
|
|
59
|
+
offer.claimUrl = claimUrl;
|
|
60
|
+
return offer;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Ask the platform where this workspace stands. `undefined` means "no answer", never "not claimed". */
|
|
67
|
+
export async function fetchHarnessOffer(env, claimUrl, doFetch = (url, init) => fetch(url, init), timeoutMs = TIMEOUT_MS) {
|
|
68
|
+
const key = apiKeyFrom(env);
|
|
69
|
+
const host = env[ReticleEnv.CLOUD_URL];
|
|
70
|
+
if (key === undefined || 0 === key.length)
|
|
71
|
+
return undefined;
|
|
72
|
+
if (host === undefined || 0 === host.length)
|
|
73
|
+
return undefined;
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
controller.abort();
|
|
77
|
+
}, timeoutMs);
|
|
78
|
+
try {
|
|
79
|
+
const res = await doFetch(`${host.replace(/\/+$/, '')}${OFFER_PATH}`, {
|
|
80
|
+
method: 'GET',
|
|
81
|
+
headers: { authorization: `Bearer ${key}` },
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
return undefined;
|
|
86
|
+
return parse(await res.text(), claimUrl);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A self-refreshing cache over the read above.
|
|
97
|
+
*
|
|
98
|
+
* Deliberately NOT a timer. A daemon that lives for days would otherwise hold an interval whose only
|
|
99
|
+
* job is to re-ask a question nobody is asking, and it would have to be torn down in a place that
|
|
100
|
+
* already has nine teardowns. Refreshing on a stale READ means the work only happens while somebody
|
|
101
|
+
* is actually looking, and the first read of a process — which is always stale — starts the fetch
|
|
102
|
+
* that the second read answers from.
|
|
103
|
+
*/
|
|
104
|
+
export function harnessOfferSource(env, claimUrl, now = () => Date.now(), load = (claim) => fetchHarnessOffer(env, claim)) {
|
|
105
|
+
let cached;
|
|
106
|
+
// `undefined` rather than 0: "never asked" is a different state from "asked at the epoch", and
|
|
107
|
+
// conflating them made a source with an injected clock never take its first read.
|
|
108
|
+
let fetchedAt;
|
|
109
|
+
let inFlight = false;
|
|
110
|
+
const refresh = () => {
|
|
111
|
+
if (inFlight)
|
|
112
|
+
return;
|
|
113
|
+
inFlight = true;
|
|
114
|
+
void load(claimUrl())
|
|
115
|
+
.then((offer) => {
|
|
116
|
+
// A failed read keeps the LAST good answer rather than blanking the card mid-session: one
|
|
117
|
+
// dropped request is not evidence that the offer changed.
|
|
118
|
+
if (offer !== undefined)
|
|
119
|
+
cached = offer;
|
|
120
|
+
fetchedAt = now();
|
|
121
|
+
})
|
|
122
|
+
.catch(() => {
|
|
123
|
+
fetchedAt = now();
|
|
124
|
+
})
|
|
125
|
+
.finally(() => {
|
|
126
|
+
inFlight = false;
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
/*
|
|
130
|
+
* Asked once at construction, not first read.
|
|
131
|
+
*
|
|
132
|
+
* Measured in a browser: a daemon pushes the impact snapshot the INSTANT a session attaches, and
|
|
133
|
+
* on a page nobody drives that is the only push there will ever be. Refreshing on the first read
|
|
134
|
+
* meant that push always carried "we have not heard", the answer landed a moment later with
|
|
135
|
+
* nobody to tell, and the HUD showed nothing for the whole session. Starting the fetch when the
|
|
136
|
+
* daemon starts puts the answer in place before any page can connect.
|
|
137
|
+
*/
|
|
138
|
+
refresh();
|
|
139
|
+
return {
|
|
140
|
+
read() {
|
|
141
|
+
if (fetchedAt === undefined || FRESH_MS <= now() - fetchedAt)
|
|
142
|
+
refresh();
|
|
143
|
+
return cached;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type SwitchFetch = (url: string, init: {
|
|
2
|
+
method: string;
|
|
3
|
+
headers: Record<string, string>;
|
|
4
|
+
body: string;
|
|
5
|
+
signal?: AbortSignal;
|
|
6
|
+
}) => Promise<{
|
|
7
|
+
ok: boolean;
|
|
8
|
+
status: number;
|
|
9
|
+
}>;
|
|
10
|
+
/** `true` when the platform accepted it. Callers use this for logging, never to block a person. */
|
|
11
|
+
export declare function writeHarnessSwitch(env: Record<string, string | undefined>, enabled: boolean, doFetch?: SwitchFetch, timeoutMs?: number): Promise<boolean>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write the harness switch back to the platform.
|
|
3
|
+
*
|
|
4
|
+
* The panel does not keep this state — it emits an intent and this puts it where the dashboard can
|
|
5
|
+
* see it, so the two surfaces cannot disagree. Only the switch is sent: somebody who chose a
|
|
6
|
+
* provider in the console must not have it reset by a panel that never rendered one. The platform's
|
|
7
|
+
* PUT is what guarantees that, not the verb -- it merges the fields it is given and leaves the rest,
|
|
8
|
+
* which its own spec pins ("leaves the fields it was not sent alone").
|
|
9
|
+
*
|
|
10
|
+
* It says PUT because that is what the platform registers. It said PATCH for one release, against a
|
|
11
|
+
* route that has only ever served GET and PUT, so every write 404'd. Nothing reported it: the call
|
|
12
|
+
* is fire-and-forget and failure here is deliberately silent, so the switch simply sprang back on
|
|
13
|
+
* the next snapshot and looked like a UI that would not stick.
|
|
14
|
+
*
|
|
15
|
+
* Failure is SILENT by design, and that deserves saying plainly rather than being discovered. A
|
|
16
|
+
* dev-only overlay that threw a dialog because a settings write timed out would be worse than one
|
|
17
|
+
* that quietly did not save; the next snapshot re-reads the platform, so a lost write shows up as
|
|
18
|
+
* the switch springing back, which is a truthful thing for it to do.
|
|
19
|
+
*/
|
|
20
|
+
import { ReticleEnv, apiKeyFrom } from '@reticlehq/core';
|
|
21
|
+
const CONFIG_PATH = '/v1/model/config';
|
|
22
|
+
/** Same budget as the read beside it: a settings write must never hold anything up. */
|
|
23
|
+
const TIMEOUT_MS = 2_000;
|
|
24
|
+
/** `true` when the platform accepted it. Callers use this for logging, never to block a person. */
|
|
25
|
+
export async function writeHarnessSwitch(env, enabled, doFetch = (url, init) => fetch(url, init), timeoutMs = TIMEOUT_MS) {
|
|
26
|
+
const key = apiKeyFrom(env);
|
|
27
|
+
const host = env[ReticleEnv.CLOUD_URL];
|
|
28
|
+
if (key === undefined || 0 === key.length)
|
|
29
|
+
return false;
|
|
30
|
+
if (host === undefined || 0 === host.length)
|
|
31
|
+
return false;
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
controller.abort();
|
|
35
|
+
}, timeoutMs);
|
|
36
|
+
try {
|
|
37
|
+
// No projectId in the body: the key IS project-scoped, and the platform refuses to let a key
|
|
38
|
+
// name somebody else's project. Sending one would be asking for a privilege we do not have.
|
|
39
|
+
const res = await doFetch(`${host.replace(/\/+$/, '')}${CONFIG_PATH}`, {
|
|
40
|
+
method: 'PUT',
|
|
41
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({ harnessEnabled: enabled }),
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
});
|
|
45
|
+
return res.ok;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type SyncReport } from './sync-cycle.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type ProjectCloud } from './cloud-config.js';
|
|
3
|
+
import type { FileSystemPort } from '../project/fs/fs-port.js';
|
|
3
4
|
interface SyncDaemonDeps {
|
|
4
5
|
reticleRoot: string;
|
|
5
6
|
/** Resolved per tick, not once: a repo linked while the daemon is alive starts syncing itself. */
|
|
@@ -29,7 +30,7 @@ interface SyncDaemonDeps {
|
|
|
29
30
|
text: string;
|
|
30
31
|
}>;
|
|
31
32
|
}
|
|
32
|
-
interface SyncDaemon {
|
|
33
|
+
export interface SyncDaemon {
|
|
33
34
|
/** Run one cycle now, whatever the timer is doing. Returns undefined when not linked. */
|
|
34
35
|
syncNow: () => Promise<SyncReport | undefined>;
|
|
35
36
|
/**
|
|
@@ -50,4 +51,23 @@ interface SyncDaemon {
|
|
|
50
51
|
* nothing until one appears, which is what lets `reticle link` take effect without a restart.
|
|
51
52
|
*/
|
|
52
53
|
export declare function startSyncDaemon(deps: SyncDaemonDeps): SyncDaemon;
|
|
54
|
+
/**
|
|
55
|
+
* Start cloud sync for a daemon and hand back the two hooks the daemon wires to it.
|
|
56
|
+
*
|
|
57
|
+
* The wiring lived in `index.ts`, which sits exactly on the thousand-line cap — so the next thing
|
|
58
|
+
* anyone needed there could not be added without a split, and this is the block that most obviously
|
|
59
|
+
* did not belong in a bootstrap file. Four calls that are all one concern: what to sync, where else
|
|
60
|
+
* to look, how a landed run wakes it, and what the panel's button does.
|
|
61
|
+
*
|
|
62
|
+
* Returns the daemon itself: the caller still owns when to nudge, when to sync now, and when to
|
|
63
|
+
* stop. Only the CONSTRUCTION moved, which is the part that was four interdependent arguments about
|
|
64
|
+
* resolving a credential — and the part a bootstrap file has no business spelling out.
|
|
65
|
+
*/
|
|
66
|
+
export declare function attachCloudSync(deps: {
|
|
67
|
+
fs: FileSystemPort;
|
|
68
|
+
reticleRoot: string;
|
|
69
|
+
homeDir: string;
|
|
70
|
+
env: NodeJS.ProcessEnv;
|
|
71
|
+
otherRoots: () => readonly string[];
|
|
72
|
+
}): SyncDaemon;
|
|
53
73
|
export {};
|
|
@@ -25,6 +25,7 @@ import { log } from '../../log.js';
|
|
|
25
25
|
import { emitSyncHook } from '../../hooks/hook-emit.js';
|
|
26
26
|
import { describeSync, runSyncCycle } from './sync-cycle.js';
|
|
27
27
|
import { diskSink, diskSource, readCloudState } from './sync-disk.js';
|
|
28
|
+
import { resolveProjectCloud } from './cloud-config.js';
|
|
28
29
|
/** How often the daemon cycles. See the note above on why this is a constant and not a curve. */
|
|
29
30
|
const DAEMON_SYNC_INTERVAL_MS = 60_000;
|
|
30
31
|
/**
|
|
@@ -271,3 +272,26 @@ export function startSyncDaemon(deps) {
|
|
|
271
272
|
},
|
|
272
273
|
};
|
|
273
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* Start cloud sync for a daemon and hand back the two hooks the daemon wires to it.
|
|
277
|
+
*
|
|
278
|
+
* The wiring lived in `index.ts`, which sits exactly on the thousand-line cap — so the next thing
|
|
279
|
+
* anyone needed there could not be added without a split, and this is the block that most obviously
|
|
280
|
+
* did not belong in a bootstrap file. Four calls that are all one concern: what to sync, where else
|
|
281
|
+
* to look, how a landed run wakes it, and what the panel's button does.
|
|
282
|
+
*
|
|
283
|
+
* Returns the daemon itself: the caller still owns when to nudge, when to sync now, and when to
|
|
284
|
+
* stop. Only the CONSTRUCTION moved, which is the part that was four interdependent arguments about
|
|
285
|
+
* resolving a credential — and the part a bootstrap file has no business spelling out.
|
|
286
|
+
*/
|
|
287
|
+
export function attachCloudSync(deps) {
|
|
288
|
+
return startSyncDaemon({
|
|
289
|
+
reticleRoot: deps.reticleRoot,
|
|
290
|
+
cloud: () => resolveProjectCloud(deps.fs, deps.reticleRoot, deps.homeDir, deps.env),
|
|
291
|
+
// Every OTHER linked repo on this machine, not just the directory the daemon was started in.
|
|
292
|
+
// One daemon serves many projects, and pushing only its own root left the rest silently
|
|
293
|
+
// reporting nothing — indistinguishable, on the dashboard, from nobody having verified anything.
|
|
294
|
+
otherRoots: () => Promise.resolve(deps.otherRoots()),
|
|
295
|
+
cloudFor: (root) => resolveProjectCloud(deps.fs, root, deps.homeDir, deps.env),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ImpactCounts, type ImpactDefect, type ImpactSnapshot } from '@reticlehq/core';
|
|
2
|
+
import type { ConfigSource } from '../cloud/harness-config.js';
|
|
2
3
|
import { ImpactStore, type ImpactFoldMeta } from './impact-store.js';
|
|
3
4
|
/**
|
|
4
5
|
* Wire the DEFAULT root — the daemon's own. Idempotent; the first one wins.
|
|
@@ -8,6 +9,13 @@ import { ImpactStore, type ImpactFoldMeta } from './impact-store.js';
|
|
|
8
9
|
* project passes one, and this is the fallback for calls that do not.
|
|
9
10
|
*/
|
|
10
11
|
export declare function initImpact(opts: {
|
|
12
|
+
/**
|
|
13
|
+
* The harness-config source, supplied by whoever wired the daemon.
|
|
14
|
+
*
|
|
15
|
+
* Passed in rather than built here: reading the platform lives in `features/harness`, and this
|
|
16
|
+
* directory may not reach for it. The daemon owns both sides.
|
|
17
|
+
*/
|
|
18
|
+
config?: ConfigSource;
|
|
11
19
|
reticleRoot: string | undefined;
|
|
12
20
|
projectName?: string;
|
|
13
21
|
now?: () => number;
|
|
@@ -50,10 +50,11 @@ export function initImpact(opts) {
|
|
|
50
50
|
return storeFor(undefined);
|
|
51
51
|
if (defaultRoot === undefined) {
|
|
52
52
|
defaultRoot = opts.reticleRoot;
|
|
53
|
-
const { projectName, now } = opts;
|
|
53
|
+
const { projectName, now, config } = opts;
|
|
54
54
|
storeOpts = {
|
|
55
55
|
...(projectName === undefined ? {} : { projectName }),
|
|
56
56
|
...(now === undefined ? {} : { now }),
|
|
57
|
+
...(config === undefined ? {} : { config }),
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
return storeFor(opts.reticleRoot);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type OfferSource } from '../cloud/harness-offer.js';
|
|
2
|
+
import type { ConfigSource } from '../cloud/harness-config.js';
|
|
1
3
|
import { type ImpactCounts, type ImpactDefect, type AccountState, type ImpactScope, type ImpactSnapshot } from '@reticlehq/core';
|
|
2
4
|
/** Read a scope, tolerating absence and corruption: a broken file starts over rather than throwing. */
|
|
3
5
|
export declare function readScope(path: string, now: number): ImpactScope;
|
|
@@ -37,6 +39,9 @@ export declare class ImpactStore {
|
|
|
37
39
|
globalRoot?: string;
|
|
38
40
|
/** Reads whether this machine is signed in. Injected so the store stays testable and pure-ish. */
|
|
39
41
|
account?: () => AccountState;
|
|
42
|
+
/** Where the workspace stands with the free harness offer. Injected so no test touches a network. */
|
|
43
|
+
offer?: OfferSource;
|
|
44
|
+
config?: ConfigSource;
|
|
40
45
|
});
|
|
41
46
|
/** Notified after every fold, so the HUD can be pushed a fresh snapshot. */
|
|
42
47
|
onChange(fn: () => void): void;
|
|
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { readAccountState } from '../cloud/account-state.js';
|
|
5
|
+
import { harnessOfferSource } from '../cloud/harness-offer.js';
|
|
5
6
|
import { ReticleDir, IMPACT_DAILY_BUCKETS, IMPACT_DEFECT_LIMIT, IMPACT_SCHEMA_VERSION, ImpactScopeSchema, addImpactCounts, emptyImpactCounts, emptyImpactRecords, estimateImpactSavings, } from '@reticlehq/core';
|
|
6
7
|
/**
|
|
7
8
|
* Where Reticle keeps the record of what it has done for you.
|
|
@@ -174,6 +175,9 @@ export class ImpactStore {
|
|
|
174
175
|
#timer;
|
|
175
176
|
#onChange;
|
|
176
177
|
#account;
|
|
178
|
+
/** Where this workspace stands with the free harness offer. Cached; see harness-offer.ts. */
|
|
179
|
+
#offer;
|
|
180
|
+
#config;
|
|
177
181
|
constructor(opts) {
|
|
178
182
|
this.#paths = impactPaths(opts.reticleRoot, opts.globalRoot ?? homedir());
|
|
179
183
|
// Resolved per snapshot, not cached: a user who runs `reticle login` in another terminal must
|
|
@@ -182,6 +186,12 @@ export class ImpactStore {
|
|
|
182
186
|
this.#now = opts.now ?? (() => Date.now());
|
|
183
187
|
this.#projectName = opts.projectName;
|
|
184
188
|
this.#dashboardUrl = readDashboardUrl(opts.reticleRoot);
|
|
189
|
+
// The claim happens in the console, so the link this project was linked to IS the claim link —
|
|
190
|
+
// built here rather than in the HUD, which has no way to know where this project points.
|
|
191
|
+
this.#offer = opts.offer ?? harnessOfferSource(process.env, () => this.#dashboardUrl);
|
|
192
|
+
// No default: the loader lives in `features/harness` and this file may not reach for it.
|
|
193
|
+
// A store built without one simply reports no harness config, which the HUD renders as no row.
|
|
194
|
+
this.#config = opts.config ?? { read: () => undefined };
|
|
185
195
|
const now = this.#now();
|
|
186
196
|
this.#project = readScope(this.#paths.project, now);
|
|
187
197
|
this.#global = readScope(this.#paths.global, now);
|
|
@@ -211,6 +221,15 @@ export class ImpactStore {
|
|
|
211
221
|
if (this.#dashboardUrl !== undefined)
|
|
212
222
|
snap.dashboardUrl = this.#dashboardUrl;
|
|
213
223
|
snap.account = this.#account();
|
|
224
|
+
const offer = this.#offer.read();
|
|
225
|
+
// Absent means "we have not heard", which the HUD renders as nothing at all. Never defaulted to
|
|
226
|
+
// `{claimed:false}`: that would advertise the offer to everyone who is offline.
|
|
227
|
+
if (offer !== undefined)
|
|
228
|
+
snap.harnessOffer = offer;
|
|
229
|
+
// Absent stays absent: the HUD reads that as "we have not heard" and renders no control.
|
|
230
|
+
const cfg = this.#config.read();
|
|
231
|
+
if (cfg !== undefined)
|
|
232
|
+
snap.harnessConfig = cfg;
|
|
214
233
|
return snap;
|
|
215
234
|
}
|
|
216
235
|
/** Write now (process exit, tests). */
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
* is a separate decision somebody can take once they believe the migration.
|
|
21
21
|
*/
|
|
22
22
|
import { parseIntentFile } from '@reticlehq/core/artifacts';
|
|
23
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
23
24
|
import { reticleDirPaths } from '../project/dir/reticle-dir.js';
|
|
24
25
|
import { withFileLock } from '../project/file-lock.js';
|
|
25
26
|
import { subjectFor } from './intent-subject.js';
|
|
26
27
|
import { emptyShard, indexFrom, IntentShardSchema, IntentStatus, recordFromIntent, serialise, shardsFrom, readJsonFile, } from './intent-shard.js';
|
|
27
|
-
const INTENT_DIR = 'intent';
|
|
28
28
|
const INDEX_FILE = 'index.json';
|
|
29
29
|
const SHARD_SUFFIX = '.json';
|
|
30
30
|
export class IntentShardStore {
|
|
@@ -37,7 +37,7 @@ export class IntentShardStore {
|
|
|
37
37
|
this.#clock = clock;
|
|
38
38
|
}
|
|
39
39
|
#dir() {
|
|
40
|
-
return `${reticleDirPaths(this.#root).root}/${
|
|
40
|
+
return `${reticleDirPaths(this.#root).root}/${ReticleDir.INTENT_SUBDIR}`;
|
|
41
41
|
}
|
|
42
42
|
#indexPath() {
|
|
43
43
|
return `${this.#dir()}/${INDEX_FILE}`;
|
|
@@ -94,7 +94,7 @@ declare const IntentRecordSchema: z.ZodObject<{
|
|
|
94
94
|
/** When it was last touched, so a stale-looking record can be told from an untouched one. */
|
|
95
95
|
updatedAt: z.ZodOptional<z.ZodNumber>;
|
|
96
96
|
}, "strip", z.ZodTypeAny, {
|
|
97
|
-
status: "proved" | "
|
|
97
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
98
98
|
state: "proved" | "declared" | "bound";
|
|
99
99
|
id: string;
|
|
100
100
|
subject: string;
|
|
@@ -119,7 +119,7 @@ declare const IntentRecordSchema: z.ZodObject<{
|
|
|
119
119
|
statement: string;
|
|
120
120
|
}[] | undefined;
|
|
121
121
|
}, {
|
|
122
|
-
status: "proved" | "
|
|
122
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
123
123
|
state: "proved" | "declared" | "bound";
|
|
124
124
|
id: string;
|
|
125
125
|
subject: string;
|
|
@@ -201,7 +201,7 @@ export declare const IntentShardSchema: z.ZodObject<{
|
|
|
201
201
|
/** When it was last touched, so a stale-looking record can be told from an untouched one. */
|
|
202
202
|
updatedAt: z.ZodOptional<z.ZodNumber>;
|
|
203
203
|
}, "strip", z.ZodTypeAny, {
|
|
204
|
-
status: "proved" | "
|
|
204
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
205
205
|
state: "proved" | "declared" | "bound";
|
|
206
206
|
id: string;
|
|
207
207
|
subject: string;
|
|
@@ -226,7 +226,7 @@ export declare const IntentShardSchema: z.ZodObject<{
|
|
|
226
226
|
statement: string;
|
|
227
227
|
}[] | undefined;
|
|
228
228
|
}, {
|
|
229
|
-
status: "proved" | "
|
|
229
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
230
230
|
state: "proved" | "declared" | "bound";
|
|
231
231
|
id: string;
|
|
232
232
|
subject: string;
|
|
@@ -255,7 +255,7 @@ export declare const IntentShardSchema: z.ZodObject<{
|
|
|
255
255
|
version: 1;
|
|
256
256
|
subject: string;
|
|
257
257
|
intents: Record<string, {
|
|
258
|
-
status: "proved" | "
|
|
258
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
259
259
|
state: "proved" | "declared" | "bound";
|
|
260
260
|
id: string;
|
|
261
261
|
subject: string;
|
|
@@ -284,7 +284,7 @@ export declare const IntentShardSchema: z.ZodObject<{
|
|
|
284
284
|
version: 1;
|
|
285
285
|
subject: string;
|
|
286
286
|
intents: Record<string, {
|
|
287
|
-
status: "proved" | "
|
|
287
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
288
288
|
state: "proved" | "declared" | "bound";
|
|
289
289
|
id: string;
|
|
290
290
|
subject: string;
|
|
@@ -319,12 +319,12 @@ declare const IntentIndexEntrySchema: z.ZodObject<{
|
|
|
319
319
|
statement: z.ZodString;
|
|
320
320
|
status: z.ZodEnum<["proposed", "agreed", "proved", "stale"]>;
|
|
321
321
|
}, "strip", z.ZodTypeAny, {
|
|
322
|
-
status: "proved" | "
|
|
322
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
323
323
|
id: string;
|
|
324
324
|
subject: string;
|
|
325
325
|
statement: string;
|
|
326
326
|
}, {
|
|
327
|
-
status: "proved" | "
|
|
327
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
328
328
|
id: string;
|
|
329
329
|
subject: string;
|
|
330
330
|
statement: string;
|
|
@@ -338,19 +338,19 @@ export declare const IntentIndexSchema: z.ZodObject<{
|
|
|
338
338
|
statement: z.ZodString;
|
|
339
339
|
status: z.ZodEnum<["proposed", "agreed", "proved", "stale"]>;
|
|
340
340
|
}, "strip", z.ZodTypeAny, {
|
|
341
|
-
status: "proved" | "
|
|
341
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
342
342
|
id: string;
|
|
343
343
|
subject: string;
|
|
344
344
|
statement: string;
|
|
345
345
|
}, {
|
|
346
|
-
status: "proved" | "
|
|
346
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
347
347
|
id: string;
|
|
348
348
|
subject: string;
|
|
349
349
|
statement: string;
|
|
350
350
|
}>, "many">;
|
|
351
351
|
}, "strip", z.ZodTypeAny, {
|
|
352
352
|
entries: {
|
|
353
|
-
status: "proved" | "
|
|
353
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
354
354
|
id: string;
|
|
355
355
|
subject: string;
|
|
356
356
|
statement: string;
|
|
@@ -358,7 +358,7 @@ export declare const IntentIndexSchema: z.ZodObject<{
|
|
|
358
358
|
version: 1;
|
|
359
359
|
}, {
|
|
360
360
|
entries: {
|
|
361
|
-
status: "proved" | "
|
|
361
|
+
status: "proved" | "proposed" | "agreed" | "stale";
|
|
362
362
|
id: string;
|
|
363
363
|
subject: string;
|
|
364
364
|
statement: string;
|
|
@@ -3,6 +3,17 @@ import { JournalRecorder, type JournalReader } from './journal-recorder.js';
|
|
|
3
3
|
/** The minimal Session surface the journal attachment needs (Session satisfies it structurally). */
|
|
4
4
|
export interface JournalTarget {
|
|
5
5
|
readonly id: string;
|
|
6
|
+
/**
|
|
7
|
+
* The `.reticle` this session's artifacts belong in, stamped on session-create before this runs.
|
|
8
|
+
*
|
|
9
|
+
* The journal used to go to the daemon's own root — wherever the agent was launched — while the
|
|
10
|
+
* run artifact written at teardown already used this one. The journal is the worse half to
|
|
11
|
+
* misroute: it carries URLs, request and response bodies and DOM text from the app under test, so
|
|
12
|
+
* a daemon started in a backend wrote that app's traffic into a repository nobody instrumented,
|
|
13
|
+
* and the `.reticle/.gitignore` meant to keep journals out of a shared repo was being written into
|
|
14
|
+
* the OTHER tree. Undefined only when no project could be resolved, which keeps the old fallback.
|
|
15
|
+
*/
|
|
16
|
+
readonly artifactRoot?: string | undefined;
|
|
6
17
|
/** Milliseconds since the session connected — the recorder's injected clock. */
|
|
7
18
|
elapsed(): number;
|
|
8
19
|
setJournal(recorder: JournalRecorder, reader?: JournalReader): void;
|
|
@@ -19,7 +19,7 @@ export function makeJournalAttach(deps) {
|
|
|
19
19
|
log('journal_skipped_unsafe_session_id', { sessionId: session.id });
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
|
-
const journal = new SessionJournal(deps.fs, deps.reticleRoot, session.id);
|
|
22
|
+
const journal = new SessionJournal(deps.fs, session.artifactRoot ?? deps.reticleRoot, session.id);
|
|
23
23
|
// Same SessionJournal is both the write sink and the read fall-through for queries after eviction.
|
|
24
24
|
session.setJournal(new JournalRecorder(journal, { now: () => session.elapsed() }), journal);
|
|
25
25
|
};
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import type { ReticleEvent } from '@reticlehq/core';
|
|
1
|
+
import type { JournalWriteLoss, ReticleEvent } from '@reticlehq/core';
|
|
2
|
+
import type { JournalReader } from './journal-recorder.js';
|
|
3
|
+
/** The slice of the ring buffer a journal-backed query reads. `RingBuffer` satisfies it structurally. */
|
|
4
|
+
export interface QueryBuffer {
|
|
5
|
+
since(cursor: number): ReticleEvent[];
|
|
6
|
+
bufferHealth(): {
|
|
7
|
+
total: number;
|
|
8
|
+
dropped: number;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
2
11
|
/** Filter bounds for a journal-backed event query. All optional; omitted = unbounded on that axis. */
|
|
3
12
|
export interface EventQueryOptions {
|
|
4
13
|
/** Lower time bound (inclusive), elapsed ms — the act cursor semantics of `eventsSince`. */
|
|
@@ -28,5 +37,30 @@ export interface EventQueryOptions {
|
|
|
28
37
|
* Overlapping seqs need no rule — appends are ordered, so the newest write already wins the dedupe.
|
|
29
38
|
*/
|
|
30
39
|
export declare function mergeEventsBySeq(journal: readonly ReticleEvent[], buffer: readonly ReticleEvent[]): ReticleEvent[];
|
|
40
|
+
/**
|
|
41
|
+
* The events a query answers with: the ring buffer's, merged with the durable journal's **only when
|
|
42
|
+
* the buffer has evicted** (so a healthy session pays no disk cost), then filtered by
|
|
43
|
+
* since/until/actionId. This is how "what did action N cause" is answered after the buffer has
|
|
44
|
+
* dropped the evidence — the substrate's whole point.
|
|
45
|
+
*/
|
|
46
|
+
export declare function readQueryEvents(reader: JournalReader | undefined, buffer: QueryBuffer, options: EventQueryOptions): Promise<ReticleEvent[]>;
|
|
47
|
+
/**
|
|
48
|
+
* What the durable ledger could not WRITE, when a query would read from it.
|
|
49
|
+
*
|
|
50
|
+
* The event ledger stops at a byte ceiling, so a journal-backed window can be missing exactly the
|
|
51
|
+
* evidence that would have refuted an absence claim. The in-band `truncated` record cannot carry
|
|
52
|
+
* this on its own: it is stamped with the `t` of the last refused event, and `filterEvents` above
|
|
53
|
+
* bounds on `t` — so a window opened after the ceiling was reached holds no marker, no events, and
|
|
54
|
+
* nothing to distinguish it from a window in which nothing happened.
|
|
55
|
+
*
|
|
56
|
+
* Reported ONLY while the buffer has evicted, which is the same condition `readQueryEvents` uses to
|
|
57
|
+
* fall through to disk. While the buffer still holds the window, the ledger's state says nothing
|
|
58
|
+
* about that window's completeness, and saying it anyway would impeach healthy verdicts for the
|
|
59
|
+
* rest of every session that ever hit its cap. Live ring data is never discarded over this.
|
|
60
|
+
*
|
|
61
|
+
* A reader with no opinion answers `undefined`, which means NOT MEASURED. It must never be read as
|
|
62
|
+
* "nothing was lost" — see `JournalReader.readWriteLoss`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function readJournalWriteLoss(reader: JournalReader | undefined, buffer: QueryBuffer): Promise<JournalWriteLoss | undefined>;
|
|
31
65
|
/** Apply since/until/actionId bounds to an event list. */
|
|
32
66
|
export declare function filterEvents(events: readonly ReticleEvent[], options: EventQueryOptions): ReticleEvent[];
|
|
@@ -44,6 +44,52 @@ export function mergeEventsBySeq(journal, buffer) {
|
|
|
44
44
|
return a.t - b.t;
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Does a query read the durable journal at all, or is the buffer still authoritative?
|
|
49
|
+
*
|
|
50
|
+
* One expression, three callers, because the two public functions below are only honest while this
|
|
51
|
+
* is true. Kept here rather than on `Session` so the rule sits beside the merge and filter rules it
|
|
52
|
+
* governs; two copies of it would drift into a verdict that impeached itself over a file it never
|
|
53
|
+
* opened.
|
|
54
|
+
*/
|
|
55
|
+
function readsJournal(reader, buffer) {
|
|
56
|
+
return reader !== undefined && buffer.bufferHealth().dropped > 0;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The events a query answers with: the ring buffer's, merged with the durable journal's **only when
|
|
60
|
+
* the buffer has evicted** (so a healthy session pays no disk cost), then filtered by
|
|
61
|
+
* since/until/actionId. This is how "what did action N cause" is answered after the buffer has
|
|
62
|
+
* dropped the evidence — the substrate's whole point.
|
|
63
|
+
*/
|
|
64
|
+
export async function readQueryEvents(reader, buffer, options) {
|
|
65
|
+
if (!readsJournal(reader, buffer)) {
|
|
66
|
+
return filterEvents(buffer.since(options.since ?? 0), options);
|
|
67
|
+
}
|
|
68
|
+
const durable = (await reader?.readEvents()) ?? [];
|
|
69
|
+
return filterEvents(mergeEventsBySeq(durable, buffer.since(0)), options);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* What the durable ledger could not WRITE, when a query would read from it.
|
|
73
|
+
*
|
|
74
|
+
* The event ledger stops at a byte ceiling, so a journal-backed window can be missing exactly the
|
|
75
|
+
* evidence that would have refuted an absence claim. The in-band `truncated` record cannot carry
|
|
76
|
+
* this on its own: it is stamped with the `t` of the last refused event, and `filterEvents` above
|
|
77
|
+
* bounds on `t` — so a window opened after the ceiling was reached holds no marker, no events, and
|
|
78
|
+
* nothing to distinguish it from a window in which nothing happened.
|
|
79
|
+
*
|
|
80
|
+
* Reported ONLY while the buffer has evicted, which is the same condition `readQueryEvents` uses to
|
|
81
|
+
* fall through to disk. While the buffer still holds the window, the ledger's state says nothing
|
|
82
|
+
* about that window's completeness, and saying it anyway would impeach healthy verdicts for the
|
|
83
|
+
* rest of every session that ever hit its cap. Live ring data is never discarded over this.
|
|
84
|
+
*
|
|
85
|
+
* A reader with no opinion answers `undefined`, which means NOT MEASURED. It must never be read as
|
|
86
|
+
* "nothing was lost" — see `JournalReader.readWriteLoss`.
|
|
87
|
+
*/
|
|
88
|
+
export async function readJournalWriteLoss(reader, buffer) {
|
|
89
|
+
if (!readsJournal(reader, buffer))
|
|
90
|
+
return undefined;
|
|
91
|
+
return (await reader?.readWriteLoss?.()) ?? undefined;
|
|
92
|
+
}
|
|
47
93
|
/** Apply since/until/actionId bounds to an event list. */
|
|
48
94
|
export function filterEvents(events, options) {
|
|
49
95
|
return events.filter((event) => (options.since === undefined || event.t >= options.since) &&
|