@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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which `.reticle` a connected session's artifacts belong in — the IO half.
|
|
3
|
+
*
|
|
4
|
+
* `resolveArtifactRoot` and `unmatchedRoot` next door are pure on purpose; this is the file that
|
|
5
|
+
* goes to disk for them: the user-level project registry, config discovery, and the one question
|
|
6
|
+
* that decides whether the daemon may write into its own working directory at all.
|
|
7
|
+
*
|
|
8
|
+
* It lives here rather than in the daemon bootstrap because it is a subject, not wiring — and
|
|
9
|
+
* because the bootstrap is at its line cap, which is the cohesion signal that rule doing its job.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { PROJECT_REGISTRY_FILE, emptyProjectRegistry, parseProjectRegistry, } from '@reticlehq/core/artifacts';
|
|
15
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
16
|
+
import { discoverProjectConfigs, hasProjectConfig, } from '../../command/cli/config/config-discovery.js';
|
|
17
|
+
import { log } from '../../log.js';
|
|
18
|
+
import { ArtifactRootReason, projectCandidatesFrom, resolveArtifactRoot, unmatchedRoot, } from './artifact-root.js';
|
|
19
|
+
/**
|
|
20
|
+
* Is the daemon's own directory a Reticle project, or is it a guest there?
|
|
21
|
+
*
|
|
22
|
+
* Answered ONCE per resolver rather than per session: a directory does not become a project between
|
|
23
|
+
* two tabs connecting, and this is the IO half `unmatchedRoot` deliberately does not do.
|
|
24
|
+
*/
|
|
25
|
+
function daemonSitsInAProject(daemonRoot) {
|
|
26
|
+
try {
|
|
27
|
+
// Either mark counts. `.reticle.json` means somebody ran `init` here; an existing `.reticle/`
|
|
28
|
+
// means Reticle has been invited into this tree before, including by a version that predates
|
|
29
|
+
// the config file. A directory with neither has never agreed to hold anybody's session data.
|
|
30
|
+
return existsSync(daemonRoot) || hasProjectConfig(dirname(daemonRoot));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Unreadable is not a licence to write. Treat it as somebody else's directory.
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function artifactRootResolver(daemonRoot) {
|
|
38
|
+
const daemonIsProject = daemonSitsInAProject(daemonRoot);
|
|
39
|
+
return (projectId, origin) => {
|
|
40
|
+
let registry = emptyProjectRegistry();
|
|
41
|
+
try {
|
|
42
|
+
const path = join(homedir(), ReticleDir.ROOT, PROJECT_REGISTRY_FILE);
|
|
43
|
+
registry = existsSync(path)
|
|
44
|
+
? parseProjectRegistry(JSON.parse(readFileSync(path, 'utf8')))
|
|
45
|
+
: registry;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// A cache that cannot be read is an empty cache, never an error: the daemon still resolves
|
|
49
|
+
// through discovery, and falls back to its own root exactly as it did before this existed.
|
|
50
|
+
}
|
|
51
|
+
let discovery = { found: [], searched: [] };
|
|
52
|
+
try {
|
|
53
|
+
discovery = discoverProjectConfigs(process.cwd());
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Same reasoning: a diagnostic search that throws must not take a tool call with it.
|
|
57
|
+
}
|
|
58
|
+
const resolved = resolveArtifactRoot({
|
|
59
|
+
projectId,
|
|
60
|
+
candidates: projectCandidatesFrom(discovery, registry),
|
|
61
|
+
daemonRoot,
|
|
62
|
+
});
|
|
63
|
+
if (resolved.reason === ArtifactRootReason.MATCHED_PROJECT)
|
|
64
|
+
return resolved;
|
|
65
|
+
// Could not name the project. The old code wrote into the daemon's directory anyway and said
|
|
66
|
+
// nothing, which put `.reticle/` — journals included — into repositories nobody had
|
|
67
|
+
// instrumented. Evidence still has to live somewhere, so it goes to the user's own
|
|
68
|
+
// `~/.reticle/unmatched/<projectId>` instead, and the daemon says so rather than leaving
|
|
69
|
+
// somebody to find the files.
|
|
70
|
+
const root = unmatchedRoot({
|
|
71
|
+
daemonRoot,
|
|
72
|
+
daemonIsProject,
|
|
73
|
+
home: homedir(),
|
|
74
|
+
...(projectId === undefined ? {} : { projectId }),
|
|
75
|
+
// Only reached when no project id survives the guard. Without it every app that never
|
|
76
|
+
// stamped one shares a single directory -- and that directory holds the durable half, so one
|
|
77
|
+
// app's learned expectations become another's.
|
|
78
|
+
...(origin === undefined ? {} : { origin }),
|
|
79
|
+
});
|
|
80
|
+
if (root !== daemonRoot) {
|
|
81
|
+
log('artifact_root_unmatched', {
|
|
82
|
+
projectId: projectId ?? null,
|
|
83
|
+
reason: resolved.reason,
|
|
84
|
+
root,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return { ...resolved, root };
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProjectCandidate, type ProjectRegistry } from '@reticlehq/core';
|
|
1
|
+
import { type ProjectCandidate, type ProjectId, type ProjectRegistry } from '@reticlehq/core';
|
|
2
2
|
import type { ConfigDiscovery } from '../../command/cli/config/config-discovery.js';
|
|
3
3
|
/**
|
|
4
4
|
* Which `.reticle/` a session's artifacts belong in.
|
|
@@ -38,7 +38,7 @@ export declare const ArtifactRootReason: {
|
|
|
38
38
|
export type ArtifactRootReason = (typeof ArtifactRootReason)[keyof typeof ArtifactRootReason];
|
|
39
39
|
interface ArtifactRootQuery {
|
|
40
40
|
/** The connected session's HELLO projectId, when it sent one. */
|
|
41
|
-
projectId:
|
|
41
|
+
projectId: ProjectId | undefined;
|
|
42
42
|
/** Every project this machine knows about. Supplied, not gathered here — this stays pure. */
|
|
43
43
|
candidates: readonly ProjectCandidate[];
|
|
44
44
|
/** Where artifacts go when the project cannot be identified. Already a `.reticle` path. */
|
|
@@ -69,4 +69,41 @@ export declare function resolveArtifactRoot(query: ArtifactRootQuery): ArtifactR
|
|
|
69
69
|
* the one confirmed a moment ago is the better survivor.
|
|
70
70
|
*/
|
|
71
71
|
export declare function projectCandidatesFrom(discovery: ConfigDiscovery, registry: ProjectRegistry): ProjectCandidate[];
|
|
72
|
+
/** Where evidence goes when no project could be named and the daemon is a guest in this tree. */
|
|
73
|
+
export declare const UNMATCHED_SUBDIR = "unmatched";
|
|
74
|
+
/**
|
|
75
|
+
* The fallback root for a session whose project could not be resolved.
|
|
76
|
+
*
|
|
77
|
+
* Falling back to the daemon's own root was unconditional and silent, and that is how Reticle came
|
|
78
|
+
* to create `.reticle/` — session journals included, carrying URLs, request and response bodies and
|
|
79
|
+
* page text — in a user's BACKEND directory. Their editor starts the daemon there; the directory
|
|
80
|
+
* was never instrumented and never agreed to hold anybody's session data. They deleted it, and the
|
|
81
|
+
* next session wrote it again.
|
|
82
|
+
*
|
|
83
|
+
* Two cases, and only one of them was ever the intended behaviour:
|
|
84
|
+
*
|
|
85
|
+
* - the daemon is sitting IN a Reticle project (a developer who ran `reticle serve` in their own
|
|
86
|
+
* app). Its root is the right answer, and it is the case the old fallback was written for.
|
|
87
|
+
* - the daemon is a guest — no `.reticle.json`, no `.reticle/` already there. Then the evidence
|
|
88
|
+
* goes to the user's own `~/.reticle/unmatched/<projectId>`, which is Reticle's to write.
|
|
89
|
+
*
|
|
90
|
+
* It is never dropped. A verdict with nowhere to live is a worse failure than one in an unexpected
|
|
91
|
+
* place, and the caller says out loud where it went.
|
|
92
|
+
*
|
|
93
|
+
* `projectId` arrives in HELLO from the page, so it is untrusted input on a path join and is held
|
|
94
|
+
* to one safe segment — the same guard session ids and flow names already pass.
|
|
95
|
+
*/
|
|
96
|
+
export declare function unmatchedRoot(query: {
|
|
97
|
+
daemonRoot: string;
|
|
98
|
+
/** Whether the daemon's own directory is a Reticle project — an IO question, answered by the caller. */
|
|
99
|
+
daemonIsProject: boolean;
|
|
100
|
+
/** The user's home directory. Passed in rather than read, so this stays pure. */
|
|
101
|
+
home: string;
|
|
102
|
+
projectId?: ProjectId | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* Where the session was served from, when it is known. Used ONLY when no project id survives the
|
|
105
|
+
* segment guard — a real id always wins, because it is an identity and this is a stand-in.
|
|
106
|
+
*/
|
|
107
|
+
origin?: string | undefined;
|
|
108
|
+
}): string;
|
|
72
109
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { ReticleDir, projectCandidates, } from '@reticlehq/core';
|
|
2
|
+
import { SAFE_SEGMENT_PATTERN, ReticleDir, fnv1a, projectCandidates, } from '@reticlehq/core';
|
|
3
3
|
/**
|
|
4
4
|
* Which `.reticle/` a session's artifacts belong in.
|
|
5
5
|
*
|
|
@@ -98,3 +98,60 @@ export function projectCandidatesFrom(discovery, registry) {
|
|
|
98
98
|
: [{ projectId: config.projectId, directory: config.directory }]);
|
|
99
99
|
return [...discovered, ...projectCandidates(registry)];
|
|
100
100
|
}
|
|
101
|
+
/** Where evidence goes when no project could be named and the daemon is a guest in this tree. */
|
|
102
|
+
export const UNMATCHED_SUBDIR = 'unmatched';
|
|
103
|
+
/** What a project with NO identity at all is called on disk. Never blank, so the path is always real. */
|
|
104
|
+
const UNNAMED_PROJECT = 'unnamed';
|
|
105
|
+
/**
|
|
106
|
+
* The bucket for a project that could not name itself but was served from somewhere.
|
|
107
|
+
*
|
|
108
|
+
* `unnamed` used to take every one of these, which made it not a project's directory but the union
|
|
109
|
+
* of every project that ever failed to identify itself — sharing one `project.json`, one
|
|
110
|
+
* `envelopes.json`, one `flake.json` and one `assertion-tiers.json`. Those are the durable half:
|
|
111
|
+
* learned routes, per-route expectations, a quarantine ledger and an anti-downgrade floor. One
|
|
112
|
+
* app's floor silently becoming another app's floor is a wrong answer, not untidy disk.
|
|
113
|
+
*
|
|
114
|
+
* And it is the ORDINARY path, not an edge: a page that never stamped an id is an app instrumented
|
|
115
|
+
* without a build plugin, a page loaded before the plugin ran, or any tree where the daemon is a
|
|
116
|
+
* guest.
|
|
117
|
+
*
|
|
118
|
+
* The origin is the next-best identity available at that moment. It does not pretend to be a
|
|
119
|
+
* project id — two apps served on one port at different times still share a bucket — but that is a
|
|
120
|
+
* far smaller wrong than every unidentified app in the world sharing one.
|
|
121
|
+
*/
|
|
122
|
+
const ORIGIN_BUCKET_PREFIX = 'origin-';
|
|
123
|
+
/**
|
|
124
|
+
* The fallback root for a session whose project could not be resolved.
|
|
125
|
+
*
|
|
126
|
+
* Falling back to the daemon's own root was unconditional and silent, and that is how Reticle came
|
|
127
|
+
* to create `.reticle/` — session journals included, carrying URLs, request and response bodies and
|
|
128
|
+
* page text — in a user's BACKEND directory. Their editor starts the daemon there; the directory
|
|
129
|
+
* was never instrumented and never agreed to hold anybody's session data. They deleted it, and the
|
|
130
|
+
* next session wrote it again.
|
|
131
|
+
*
|
|
132
|
+
* Two cases, and only one of them was ever the intended behaviour:
|
|
133
|
+
*
|
|
134
|
+
* - the daemon is sitting IN a Reticle project (a developer who ran `reticle serve` in their own
|
|
135
|
+
* app). Its root is the right answer, and it is the case the old fallback was written for.
|
|
136
|
+
* - the daemon is a guest — no `.reticle.json`, no `.reticle/` already there. Then the evidence
|
|
137
|
+
* goes to the user's own `~/.reticle/unmatched/<projectId>`, which is Reticle's to write.
|
|
138
|
+
*
|
|
139
|
+
* It is never dropped. A verdict with nowhere to live is a worse failure than one in an unexpected
|
|
140
|
+
* place, and the caller says out loud where it went.
|
|
141
|
+
*
|
|
142
|
+
* `projectId` arrives in HELLO from the page, so it is untrusted input on a path join and is held
|
|
143
|
+
* to one safe segment — the same guard session ids and flow names already pass.
|
|
144
|
+
*/
|
|
145
|
+
export function unmatchedRoot(query) {
|
|
146
|
+
if (query.daemonIsProject)
|
|
147
|
+
return query.daemonRoot;
|
|
148
|
+
const id = query.projectId ?? '';
|
|
149
|
+
const named = SAFE_SEGMENT_PATTERN.test(id) && !id.includes('..');
|
|
150
|
+
return join(query.home, ReticleDir.ROOT, UNMATCHED_SUBDIR, named ? id : unnamedSegment(query.origin));
|
|
151
|
+
}
|
|
152
|
+
/** Hashed, not spelled: an origin carries a host and a port, and neither belongs in a path segment. */
|
|
153
|
+
function unnamedSegment(origin) {
|
|
154
|
+
if (origin === undefined || 0 === origin.trim().length)
|
|
155
|
+
return UNNAMED_PROJECT;
|
|
156
|
+
return `${ORIGIN_BUCKET_PREFIX}${fnv1a(origin.trim())}`;
|
|
157
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The values a drive types into this project's fields, kept so they are paid for once.
|
|
3
|
+
*
|
|
4
|
+
* A generated field value is not a model's opinion to be re-asked every run. It is a FIXTURE: the
|
|
5
|
+
* same label on the same app wants the same value tomorrow, a replay must send exactly what the
|
|
6
|
+
* recording sent or it is not a replay, and a human who dislikes "Acme Corp" should be able to open
|
|
7
|
+
* a file and write something else rather than argue with a model about it. So it lives beside the
|
|
8
|
+
* flows it belongs to, in git, in the project's own directory.
|
|
9
|
+
*
|
|
10
|
+
* Read once at the start of a drive and written once at the end: a drive that fills twenty fields
|
|
11
|
+
* must not write the file twenty times, and nothing else in the process is competing for it.
|
|
12
|
+
*
|
|
13
|
+
* Every failure is silent and answers "no values". A drive must never fail over a fixtures file,
|
|
14
|
+
* and the fallback -- the label heuristic -- is what every drive used before this existed.
|
|
15
|
+
*/
|
|
16
|
+
import type { FileSystemPort } from '../fs/fs-port.js';
|
|
17
|
+
/** What a drive holds while it runs: a read-through map plus whatever it learned. */
|
|
18
|
+
export interface FillValueStore {
|
|
19
|
+
get(label: string): string | undefined;
|
|
20
|
+
set(label: string, value: string): void;
|
|
21
|
+
/** Persist, if anything was learned. Silent on failure, like every read here. */
|
|
22
|
+
flush(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A store that remembers within one drive and writes nothing.
|
|
26
|
+
*
|
|
27
|
+
* Used when there is no project directory to write to. A drive must never fail over its fixtures
|
|
28
|
+
* file, and that includes failing to FIND one: the values still de-duplicate inside the run, they
|
|
29
|
+
* are simply not carried into the next one.
|
|
30
|
+
*/
|
|
31
|
+
export declare function memoryFillValues(): FillValueStore;
|
|
32
|
+
export declare function openFillValues(fs: FileSystemPort, root: string | undefined): Promise<FillValueStore>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The values a drive types into this project's fields, kept so they are paid for once.
|
|
3
|
+
*
|
|
4
|
+
* A generated field value is not a model's opinion to be re-asked every run. It is a FIXTURE: the
|
|
5
|
+
* same label on the same app wants the same value tomorrow, a replay must send exactly what the
|
|
6
|
+
* recording sent or it is not a replay, and a human who dislikes "Acme Corp" should be able to open
|
|
7
|
+
* a file and write something else rather than argue with a model about it. So it lives beside the
|
|
8
|
+
* flows it belongs to, in git, in the project's own directory.
|
|
9
|
+
*
|
|
10
|
+
* Read once at the start of a drive and written once at the end: a drive that fills twenty fields
|
|
11
|
+
* must not write the file twenty times, and nothing else in the process is competing for it.
|
|
12
|
+
*
|
|
13
|
+
* Every failure is silent and answers "no values". A drive must never fail over a fixtures file,
|
|
14
|
+
* and the fallback -- the label heuristic -- is what every drive used before this existed.
|
|
15
|
+
*/
|
|
16
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
/** Stamped so a later format change can tell an old file from a broken one. */
|
|
19
|
+
const VERSION = 1;
|
|
20
|
+
/** A label is a UI string and can be anything; a value that is not a string is not usable. */
|
|
21
|
+
function parse(text) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(text);
|
|
24
|
+
if ('object' !== typeof raw || null === raw)
|
|
25
|
+
return {};
|
|
26
|
+
const values = raw.values;
|
|
27
|
+
if ('object' !== typeof values || null === values)
|
|
28
|
+
return {};
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const [label, value] of Object.entries(values))
|
|
31
|
+
if ('string' === typeof value && 0 < value.length)
|
|
32
|
+
out[label] = value;
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A store that remembers within one drive and writes nothing.
|
|
41
|
+
*
|
|
42
|
+
* Used when there is no project directory to write to. A drive must never fail over its fixtures
|
|
43
|
+
* file, and that includes failing to FIND one: the values still de-duplicate inside the run, they
|
|
44
|
+
* are simply not carried into the next one.
|
|
45
|
+
*/
|
|
46
|
+
export function memoryFillValues() {
|
|
47
|
+
const values = new Map();
|
|
48
|
+
return {
|
|
49
|
+
get: (label) => values.get(label),
|
|
50
|
+
set: (label, value) => void values.set(label, value),
|
|
51
|
+
flush: () => Promise.resolve(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function openFillValues(fs, root) {
|
|
55
|
+
if (root === undefined || 0 === root.length)
|
|
56
|
+
return memoryFillValues();
|
|
57
|
+
const path = join(root, ReticleDir.FILL_VALUES_FILE);
|
|
58
|
+
let values = {};
|
|
59
|
+
try {
|
|
60
|
+
if (await fs.exists(path))
|
|
61
|
+
values = parse(await fs.readFile(path));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* an unreadable fixtures file is the same as an absent one */
|
|
65
|
+
}
|
|
66
|
+
let learned = false;
|
|
67
|
+
return {
|
|
68
|
+
get: (label) => values[label],
|
|
69
|
+
set: (label, value) => {
|
|
70
|
+
if (values[label] === value)
|
|
71
|
+
return;
|
|
72
|
+
values[label] = value;
|
|
73
|
+
learned = true;
|
|
74
|
+
},
|
|
75
|
+
async flush() {
|
|
76
|
+
if (!learned)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
await fs.mkdir(root);
|
|
80
|
+
const file = { version: VERSION, values };
|
|
81
|
+
await fs.writeFile(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* the drive already happened; losing the cache costs a regeneration, not a result */
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type FlowName, type SessionId } from '@reticlehq/core';
|
|
1
|
+
import { type ProjectId, type FlowName, type SessionId } from '@reticlehq/core';
|
|
2
2
|
import { ContractReadError, type CapabilitiesContract, type RunId } from '@reticlehq/core';
|
|
3
3
|
import type { FileSystemPort } from '../fs/fs-port.js';
|
|
4
4
|
/** Resolved absolute paths inside a `.reticle/` root. Pure: join only, no IO, no cwd. */
|
|
@@ -17,6 +17,8 @@ export interface ReticleDirPaths {
|
|
|
17
17
|
impact: string;
|
|
18
18
|
/**.../.reticle/intent.json (what changes were supposed to make true — git-checked) */
|
|
19
19
|
intent: string;
|
|
20
|
+
/**.../.reticle/fill-values.json (what a drive types into each labelled field — git-checked) */
|
|
21
|
+
fillValues: string;
|
|
20
22
|
/**.../.reticle/visual (PNG baselines + diffs) */
|
|
21
23
|
visual: string;
|
|
22
24
|
/**.../.reticle/runs (verification-run artifacts) */
|
|
@@ -43,6 +45,14 @@ export declare function sessionDirPath(root: string, sessionId: SessionId): stri
|
|
|
43
45
|
export declare function journalEventsPath(root: string, sessionId: SessionId): string;
|
|
44
46
|
/** The append-only action ledger path for a session (.reticle/sessions/<id>/actions.jsonl). */
|
|
45
47
|
export declare function journalActionsPath(root: string, sessionId: SessionId): string;
|
|
48
|
+
/**
|
|
49
|
+
* The event ledger's closure report for a session (.reticle/sessions/<id>/events.closed.json).
|
|
50
|
+
*
|
|
51
|
+
* A sidecar rather than a header inside `events.jsonl`, because the ledger is append-only and
|
|
52
|
+
* readers track a byte offset into it: a header would have to be rewritten in place, which shrinks
|
|
53
|
+
* or shifts the file and silently invalidates every cursor pointing at it.
|
|
54
|
+
*/
|
|
55
|
+
export declare function journalClosedPath(root: string, sessionId: SessionId): string;
|
|
46
56
|
/**
|
|
47
57
|
* A sessionId must be a single safe path segment before it is joined into a disk path (rejects
|
|
48
58
|
* '../', slashes, absolute, dotfiles). Session labels are user/tab-supplied, so this guard runs
|
|
@@ -82,9 +92,9 @@ export declare function visualDiffPath(root: string, name: string, runtime?: str
|
|
|
82
92
|
* apps' flows of the same name; without one it's the flat legacy path (`.reticle/flows/<name>.json`),
|
|
83
93
|
* which is also where pre-existing (untagged) flows stay. Callers pass ONLY a validated projectId.
|
|
84
94
|
*/
|
|
85
|
-
export declare function flowPath(root: string, name: FlowName, projectId?:
|
|
95
|
+
export declare function flowPath(root: string, name: FlowName, projectId?: ProjectId): string;
|
|
86
96
|
/** The directory a flow write targets: the per-project subdir, or the flat flows root for global. */
|
|
87
|
-
export declare function flowDir(root: string, projectId?:
|
|
97
|
+
export declare function flowDir(root: string, projectId?: ProjectId): string;
|
|
88
98
|
/**
|
|
89
99
|
* The directory that must exist before a flow of this name can be written.
|
|
90
100
|
*
|
|
@@ -96,7 +106,7 @@ export declare function flowDir(root: string, projectId?: string): string;
|
|
|
96
106
|
* Derived from the resolved path rather than by splitting the name, so it cannot disagree with
|
|
97
107
|
* `flowPath` about where the file actually goes.
|
|
98
108
|
*/
|
|
99
|
-
export declare function flowParentDir(root: string, name: FlowName, projectId?:
|
|
109
|
+
export declare function flowParentDir(root: string, name: FlowName, projectId?: ProjectId): string;
|
|
100
110
|
/**
|
|
101
111
|
* A flow name may be NAMESPACED — `onboarding/signup` — and still never escapes `.reticle/`.
|
|
102
112
|
*
|
|
@@ -10,6 +10,7 @@ export function reticleDirPaths(root) {
|
|
|
10
10
|
project: join(root, ReticleDir.PROJECT_FILE),
|
|
11
11
|
impact: join(root, ReticleDir.IMPACT_FILE),
|
|
12
12
|
intent: join(root, ReticleDir.INTENT_FILE),
|
|
13
|
+
fillValues: join(root, ReticleDir.FILL_VALUES_FILE),
|
|
13
14
|
visual: join(root, ReticleDir.VISUAL_SUBDIR),
|
|
14
15
|
runs: join(root, ReticleDir.RUNS_SUBDIR),
|
|
15
16
|
sessions: join(root, ReticleDir.SESSIONS_SUBDIR),
|
|
@@ -36,6 +37,16 @@ export function journalEventsPath(root, sessionId) {
|
|
|
36
37
|
export function journalActionsPath(root, sessionId) {
|
|
37
38
|
return join(sessionDirPath(root, sessionId), ReticleDir.JOURNAL_ACTIONS_FILE);
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The event ledger's closure report for a session (.reticle/sessions/<id>/events.closed.json).
|
|
42
|
+
*
|
|
43
|
+
* A sidecar rather than a header inside `events.jsonl`, because the ledger is append-only and
|
|
44
|
+
* readers track a byte offset into it: a header would have to be rewritten in place, which shrinks
|
|
45
|
+
* or shifts the file and silently invalidates every cursor pointing at it.
|
|
46
|
+
*/
|
|
47
|
+
export function journalClosedPath(root, sessionId) {
|
|
48
|
+
return join(sessionDirPath(root, sessionId), ReticleDir.JOURNAL_EVENTS_CLOSED_FILE);
|
|
49
|
+
}
|
|
39
50
|
/**
|
|
40
51
|
* A sessionId must be a single safe path segment before it is joined into a disk path (rejects
|
|
41
52
|
* '../', slashes, absolute, dotfiles). Session labels are user/tab-supplied, so this guard runs
|
|
@@ -12,10 +12,31 @@ export interface FileSystemPort {
|
|
|
12
12
|
* that lands on a `\n` boundary (the journal's line terminator, 1 ASCII byte) so the returned tail
|
|
13
13
|
* starts on a valid UTF-8 char boundary. Optional — a FileSystemPort that omits it makes readers fall
|
|
14
14
|
* back to a whole-file read.
|
|
15
|
+
*
|
|
16
|
+
* `maxBytes` caps what ONE call materialises as a single string. It is not a performance knob: V8
|
|
17
|
+
* cannot build a string longer than 0x1fffffe8 characters, and `Buffer.prototype.toString` THROWS
|
|
18
|
+
* past that rather than returning a shorter one — so a tail bigger than the ceiling is not a slow
|
|
19
|
+
* read, it is a failed one. When the cap bites, the NEWEST bytes are kept (a journal tail is read
|
|
20
|
+
* to answer about what just happened).
|
|
21
|
+
*
|
|
22
|
+
* Two guarantees make the returned text safe to account for in bytes, and an implementation owes
|
|
23
|
+
* both:
|
|
24
|
+
*
|
|
25
|
+
* - `from` is where the text ACTUALLY starts. It equals `byteOffset` when nothing was skipped,
|
|
26
|
+
* and a caller can compare the two to see that a cap bit. (Omitted by a port that does not
|
|
27
|
+
* implement the cap — read that as `byteOffset`, since such a port never skips.)
|
|
28
|
+
* - When the cap moves the start, `from` lands just past a `\n`, so the text begins at a record
|
|
29
|
+
* boundary and never with the replacement char of a character the window cut in half. A
|
|
30
|
+
* window holding no `\n` at all returns empty text with `from` at the end of what was read.
|
|
31
|
+
*
|
|
32
|
+
* `size` is the file's length as `fstat` reported it, which is NOT where the returned text ends:
|
|
33
|
+
* a truncation between the stat and the read, or any short read, returns less. Advance a cursor
|
|
34
|
+
* by what arrived, never by `size`.
|
|
15
35
|
*/
|
|
16
|
-
readFileFrom?(path: string, byteOffset: number): Promise<{
|
|
36
|
+
readFileFrom?(path: string, byteOffset: number, maxBytes?: number): Promise<{
|
|
17
37
|
text: string;
|
|
18
38
|
size: number;
|
|
39
|
+
from?: number;
|
|
19
40
|
}>;
|
|
20
41
|
writeFile(path: string, data: string): Promise<void>;
|
|
21
42
|
/** Append UTF-8 text, creating the file if absent — for the append-only JSONL journal. */
|
|
@@ -1,18 +1,46 @@
|
|
|
1
1
|
import { access, appendFile, mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
2
|
+
/** The JSONL record terminator, as a byte — the only boundary a capped tail read can align to. */
|
|
3
|
+
const NEWLINE_BYTE = 0x0a;
|
|
2
4
|
/** Production adapter — the single import site of node:fs/promises in the server. */
|
|
3
5
|
export function createNodeFileSystem() {
|
|
4
6
|
return {
|
|
5
7
|
readFile: (path) => readFile(path, 'utf8'),
|
|
6
|
-
readFileFrom: async (path, byteOffset) => {
|
|
8
|
+
readFileFrom: async (path, byteOffset, maxBytes) => {
|
|
7
9
|
const fh = await open(path, 'r');
|
|
8
10
|
try {
|
|
9
11
|
const { size } = await fh.stat();
|
|
10
12
|
if (byteOffset >= size)
|
|
11
|
-
return { text: '', size };
|
|
12
|
-
|
|
13
|
+
return { text: '', size, from: byteOffset };
|
|
14
|
+
// Keep the newest bytes when the tail is over the caller's ceiling. Clamped into the file
|
|
15
|
+
// rather than trusted: a ceiling of zero would otherwise size a negative buffer.
|
|
16
|
+
const cut = maxBytes === undefined ? byteOffset : Math.max(byteOffset, size - Math.max(maxBytes, 0));
|
|
17
|
+
const capped = cut > byteOffset;
|
|
18
|
+
// One byte BEFORE that cut, so a cut landing exactly on a record boundary is recognised as
|
|
19
|
+
// one instead of sacrificing the record after it.
|
|
20
|
+
const start = capped ? cut - 1 : byteOffset;
|
|
21
|
+
const length = size - start;
|
|
13
22
|
const buf = Buffer.allocUnsafe(length);
|
|
14
|
-
await fh.read(buf, 0, length,
|
|
15
|
-
|
|
23
|
+
const { bytesRead } = await fh.read(buf, 0, length, start);
|
|
24
|
+
// ONLY what the read returned. `allocUnsafe` hands back whatever was last in that heap
|
|
25
|
+
// block, and `fstat` runs one syscall ahead of `read` — so a file truncated in between, or
|
|
26
|
+
// any short read, would otherwise decode that memory and hand it back as journal text.
|
|
27
|
+
const got = buf.subarray(0, bytesRead);
|
|
28
|
+
if (!capped)
|
|
29
|
+
return { text: got.toString('utf8'), size, from: start };
|
|
30
|
+
// The ceiling moved the start, so the window may open inside a record whose head was never
|
|
31
|
+
// read. Drop up to the first boundary in BYTES, before decoding: `\n` is one byte and
|
|
32
|
+
// cannot occur inside a multi-byte sequence, so everything past it is intact UTF-8 and a
|
|
33
|
+
// whole number of records.
|
|
34
|
+
const newline = got.indexOf(NEWLINE_BYTE);
|
|
35
|
+
// No boundary in the whole window: there is no record here that can be read, and half of
|
|
36
|
+
// one is worse than none. `from` still says how far the read reached.
|
|
37
|
+
if (-1 === newline)
|
|
38
|
+
return { text: '', size, from: start + bytesRead };
|
|
39
|
+
return {
|
|
40
|
+
text: got.subarray(newline + 1).toString('utf8'),
|
|
41
|
+
size,
|
|
42
|
+
from: start + newline + 1,
|
|
43
|
+
};
|
|
16
44
|
}
|
|
17
45
|
finally {
|
|
18
46
|
await fh.close();
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
2
|
interface RouteLearningSession {
|
|
3
3
|
url: string;
|
|
4
|
+
/**
|
|
5
|
+
* The `.reticle` this session's artifacts belong in, stamped on session-create. Undefined only
|
|
6
|
+
* for a caller that does not stamp one, which resolves to the daemon's own root.
|
|
7
|
+
*/
|
|
8
|
+
artifactRoot?: string | undefined;
|
|
4
9
|
onEvent(handler: (event: ReticleEvent) => void): () => void;
|
|
5
10
|
}
|
|
6
11
|
interface RouteLearningBridge {
|
|
@@ -19,6 +24,15 @@ export declare function routeFromEvent(event: ReticleEvent): string | undefined;
|
|
|
19
24
|
/** Read the same route shape from a session's absolute URL. */
|
|
20
25
|
export declare function routeFromUrl(value: string): string | undefined;
|
|
21
26
|
export declare function routesFromEvents(events: readonly ReticleEvent[]): string[];
|
|
22
|
-
/**
|
|
23
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Persist initial and subsequent routes for existing and future browser sessions.
|
|
29
|
+
*
|
|
30
|
+
* `storeFor` resolves the store for ONE session's artifact root, rather than the daemon being handed
|
|
31
|
+
* a single store bound to its own `process.cwd()`. That binding was right only when the daemon
|
|
32
|
+
* happened to be started inside the app it was driving — and a user-scoped MCP registration, the
|
|
33
|
+
* common case, starts it wherever the editor's cwd is. The routes of every app then landed in that
|
|
34
|
+
* directory, which is how a `.reticle/` kept reappearing in a backend nobody had instrumented,
|
|
35
|
+
* while the driven project's own memory never filled.
|
|
36
|
+
*/
|
|
37
|
+
export declare function attachRouteLearning(bridge: RouteLearningBridge, storeFor: (root: string | undefined) => RouteLearningStore): void;
|
|
24
38
|
export {};
|
|
@@ -26,20 +26,35 @@ export function routesFromEvents(events) {
|
|
|
26
26
|
return route === undefined ? [] : [route];
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Persist initial and subsequent routes for existing and future browser sessions.
|
|
31
|
+
*
|
|
32
|
+
* `storeFor` resolves the store for ONE session's artifact root, rather than the daemon being handed
|
|
33
|
+
* a single store bound to its own `process.cwd()`. That binding was right only when the daemon
|
|
34
|
+
* happened to be started inside the app it was driving — and a user-scoped MCP registration, the
|
|
35
|
+
* common case, starts it wherever the editor's cwd is. The routes of every app then landed in that
|
|
36
|
+
* directory, which is how a `.reticle/` kept reappearing in a backend nobody had instrumented,
|
|
37
|
+
* while the driven project's own memory never filled.
|
|
38
|
+
*/
|
|
39
|
+
export function attachRouteLearning(bridge, storeFor) {
|
|
31
40
|
const attached = new WeakSet();
|
|
32
|
-
|
|
41
|
+
// Keyed by STORE rather than by root path: `storeFor` already answers one store per root, so this
|
|
42
|
+
// cannot disagree with it about which roots are distinct.
|
|
43
|
+
const pending = new Map();
|
|
33
44
|
let flushTimer;
|
|
34
45
|
const flush = () => {
|
|
35
46
|
flushTimer = undefined;
|
|
36
|
-
const
|
|
47
|
+
const batches = [...pending];
|
|
37
48
|
pending.clear();
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
for (const [store, routes] of batches) {
|
|
50
|
+
if (routes.size > 0)
|
|
51
|
+
void store.recordRoutes([...routes]).catch(() => undefined);
|
|
52
|
+
}
|
|
40
53
|
};
|
|
41
|
-
const queue = (route) => {
|
|
42
|
-
pending.
|
|
54
|
+
const queue = (store, route) => {
|
|
55
|
+
const routes = pending.get(store) ?? new Set();
|
|
56
|
+
routes.add(route);
|
|
57
|
+
pending.set(store, routes);
|
|
43
58
|
if (flushTimer !== undefined)
|
|
44
59
|
clearTimeout(flushTimer);
|
|
45
60
|
flushTimer = setTimeout(flush, ROUTE_PERSIST_DEBOUNCE_MS);
|
|
@@ -48,13 +63,14 @@ export function attachRouteLearning(bridge, project) {
|
|
|
48
63
|
if (attached.has(session))
|
|
49
64
|
return;
|
|
50
65
|
attached.add(session);
|
|
66
|
+
const store = storeFor(session.artifactRoot);
|
|
51
67
|
const initial = routeFromUrl(session.url);
|
|
52
68
|
if (initial !== undefined)
|
|
53
|
-
queue(initial);
|
|
69
|
+
queue(store, initial);
|
|
54
70
|
session.onEvent((event) => {
|
|
55
71
|
const route = routeFromEvent(event);
|
|
56
72
|
if (route !== undefined)
|
|
57
|
-
queue(route);
|
|
73
|
+
queue(store, route);
|
|
58
74
|
});
|
|
59
75
|
};
|
|
60
76
|
for (const session of bridge.sessions.all())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project memory store for ONE artifact root.
|
|
3
|
+
*
|
|
4
|
+
* `deps.project` is bound to the daemon's own root at construction, which is right only when the
|
|
5
|
+
* daemon happens to have been started inside the app it is driving — and a user-scoped MCP
|
|
6
|
+
* registration, the common case, starts it wherever the editor's cwd is. So every run record, every
|
|
7
|
+
* learned route and every observability high-water mark was written into that directory: a
|
|
8
|
+
* `.reticle/` appearing in a backend nobody instrumented, and a driven project whose own memory
|
|
9
|
+
* stayed empty. The cloud half of `recordReplayRun` had already been moved onto the session's root
|
|
10
|
+
* and its local half left behind, which is how a replay outcome could reach the dashboard and not
|
|
11
|
+
* the project it belonged to.
|
|
12
|
+
*
|
|
13
|
+
* Reads and writes both go through here, deliberately: a write that resolves to the app while a
|
|
14
|
+
* read still answers from the daemon's directory is worse than either alone — the record lands and
|
|
15
|
+
* is then invisible to the tool that exists to report it.
|
|
16
|
+
*
|
|
17
|
+
* Rebinding is cheap (a filesystem port, a root and a clock) and the single-project case allocates
|
|
18
|
+
* nothing: when the resolved root IS the daemon's, `deps.project` comes back unchanged.
|
|
19
|
+
*/
|
|
20
|
+
import { ProjectStore } from './project-store.js';
|
|
21
|
+
import type { FileSystemPort } from './fs/fs-port.js';
|
|
22
|
+
import type { ToolDeps } from '../../surface/tools/tool-kit.js';
|
|
23
|
+
export declare function projectForRoot(deps: ToolDeps, root: string): ProjectStore;
|
|
24
|
+
/**
|
|
25
|
+
* The same question asked by the daemon rather than by a tool: one store per artifact root, so a
|
|
26
|
+
* session's learned routes land in the app that was driven and not in whatever directory the daemon
|
|
27
|
+
* was launched from. The daemon's own store is seeded in, so the single-project case allocates
|
|
28
|
+
* nothing and keeps the instance every other caller already holds.
|
|
29
|
+
*/
|
|
30
|
+
export declare function projectStoreResolver(fs: FileSystemPort, daemonStore: ProjectStore, daemonRoot: string, now: () => number): (root: string | undefined) => ProjectStore;
|