@reticlehq/server 2.9.0 → 2.11.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 +64 -26
- package/dist/capsule/capsule-store.d.ts +2 -2
- package/dist/cli/cli-parse.d.ts +1 -1
- package/dist/cli/cli-parse.js +1 -1
- package/dist/cli/cli-verify.js +1 -1
- package/dist/crawl/crawl.d.ts +7 -2
- package/dist/crawl/crawl.js +52 -9
- package/dist/dev/stale-issue-guard.d.ts +43 -0
- package/dist/dev/stale-issue-guard.js +75 -0
- package/dist/domain/domain-tools.js +3 -2
- package/dist/ee/audit-log.js +2 -2
- package/dist/events/body-failures.d.ts +2 -2
- package/dist/events/contradiction-folds.d.ts +45 -0
- package/dist/events/contradiction-folds.js +42 -0
- package/dist/events/contradictions.d.ts +19 -1
- package/dist/events/contradictions.js +17 -2
- package/dist/events/echo-mismatch.d.ts +2 -2
- package/dist/events/predicate-asks.d.ts +17 -0
- package/dist/events/predicate-asks.js +26 -0
- package/dist/events/predicate-eval.js +211 -24
- package/dist/events/predicate-parse.js +9 -3
- package/dist/events/predicate-schema.d.ts +18 -0
- package/dist/events/predicate-schema.js +55 -0
- package/dist/events/stale-response.d.ts +2 -3
- package/dist/events/stale-response.js +49 -2
- package/dist/events/unit-mismatch.d.ts +2 -2
- package/dist/flows/flow-tools.js +34 -5
- package/dist/flows/predicate-to-expect.js +8 -0
- package/dist/flows/verify-change-tools.js +6 -2
- package/dist/honesty/blind-spots.d.ts +9 -0
- package/dist/honesty/blind-spots.js +17 -4
- package/dist/honesty/gap-ledger.d.ts +52 -0
- package/dist/honesty/gap-ledger.js +63 -0
- package/dist/honesty/instrumentation-gaps.d.ts +55 -0
- package/dist/honesty/instrumentation-gaps.js +21 -0
- package/dist/honesty/observability.d.ts +37 -0
- package/dist/honesty/observability.js +40 -0
- package/dist/honesty/verified.js +16 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +57 -0
- package/dist/init/mcp.d.ts +1 -2
- package/dist/init/mcp.js +4 -1
- package/dist/init/node-io.js +3 -0
- package/dist/init/run.d.ts +18 -0
- package/dist/init/run.js +34 -3
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +33 -10
- package/dist/journal/envelope.d.ts +24 -24
- package/dist/license/license.d.ts +76 -5
- package/dist/license/license.js +93 -29
- package/dist/mcp/mcp-proxy.d.ts +2 -27
- package/dist/mcp/mcp-proxy.js +4 -88
- package/dist/mcp/mcp.d.ts +18 -2
- package/dist/mcp/mcp.js +31 -10
- package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
- package/dist/mcp/proxy-daemon-probe.js +48 -0
- package/dist/mcp/proxy-handshake.js +1 -1
- package/dist/mcp/server-instructions.js +2 -2
- package/dist/mcp/sse-frame-parser.d.ts +19 -0
- package/dist/mcp/sse-frame-parser.js +41 -0
- package/dist/project/artifact-root.d.ts +83 -0
- package/dist/project/artifact-root.js +112 -0
- package/dist/project/memory-fs.d.ts +9 -0
- package/dist/project/memory-fs.js +88 -0
- package/dist/project/project-store.d.ts +17 -0
- package/dist/project/project-store.js +33 -0
- package/dist/project/remember-project.d.ts +24 -0
- package/dist/project/remember-project.js +40 -0
- package/dist/project/session-root.d.ts +15 -0
- package/dist/project/session-root.js +31 -0
- package/dist/runs/run-tools.js +2 -1
- package/dist/session/lease-availability.d.ts +40 -0
- package/dist/session/lease-availability.js +43 -0
- package/dist/session/no-session-diagnosis.d.ts +17 -0
- package/dist/session/no-session-diagnosis.js +41 -8
- package/dist/session/no-session-watch.js +7 -0
- package/dist/session/session-readiness.js +1 -1
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.js +9 -0
- package/dist/session/stall-clock.d.ts +18 -0
- package/dist/session/stall-clock.js +48 -0
- package/dist/telemetry/daemon-telemetry.js +16 -22
- package/dist/telemetry/license-activation.d.ts +48 -0
- package/dist/telemetry/license-activation.js +57 -0
- package/dist/telemetry/session-metrics.d.ts +1 -1
- package/dist/telemetry/session-metrics.js +1 -1
- package/dist/telemetry/telemetry.d.ts +12 -3
- package/dist/telemetry/telemetry.js +34 -5
- package/dist/tools/act-tools.js +34 -4
- package/dist/tools/assert-verdict.d.ts +2 -0
- package/dist/tools/assert-verdict.js +24 -1
- package/dist/tools/contract-tools.js +5 -3
- package/dist/tools/coverage-tools.js +33 -0
- package/dist/tools/dynamic-tools.js +1 -1
- package/dist/tools/error-recovery.js +1 -1
- package/dist/tools/feedback-tools.js +31 -6
- package/dist/tools/invoke-tool.d.ts +1 -1
- package/dist/tools/invoke-tool.js +10 -5
- package/dist/tools/observe-tools.js +8 -2
- package/dist/tools/tool-kit.d.ts +46 -4
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tool-surface.js +2 -3
- package/dist/tools/tools.js +16 -1
- package/docs/README.md +1 -0
- package/docs/agent-cheatsheet.md +2 -2
- package/docs/capabilities.mdx +4 -4
- package/docs/cli/hunt.mdx +2 -2
- package/docs/deploy-checks.md +2 -2
- package/docs/docs.json +2 -2
- package/docs/enterprise.md +9 -3
- package/docs/flows.md +4 -4
- package/docs/gates.md +1 -1
- package/docs/license-activation.mdx +145 -0
- package/docs/recipes.mdx +2 -2
- package/docs/telemetry-contract.md +18 -12
- package/docs/telemetry-events.mdx +1 -34
- package/docs/telemetry.md +0 -1
- package/docs/tools-overview.mdx +1 -1
- package/docs/tools-session-and-feedback.mdx +5 -0
- package/docs/tools-tools-and-run.mdx +15 -15
- package/docs/usage.md +3 -3
- package/package.json +2 -2
- package/dist/telemetry/instrumentation-stall.d.ts +0 -50
- package/dist/telemetry/instrumentation-stall.js +0 -90
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type ProjectCandidate, type ProjectRegistry } from '@reticlehq/core';
|
|
2
|
+
import type { ConfigDiscovery } from '../cli/config-discovery.js';
|
|
3
|
+
/**
|
|
4
|
+
* Which `.reticle/` a session's artifacts belong in.
|
|
5
|
+
*
|
|
6
|
+
* Everything Reticle persists — flows, the capability contract, baselines, capsules, the cross-run
|
|
7
|
+
* project memory — used to resolve against the DAEMON's own `process.cwd()`. That is a statement
|
|
8
|
+
* about where the daemon was launched and it was being read as a statement about which project is
|
|
9
|
+
* being verified. Those are not the same thing and are usually not even the same tree: a user-scoped
|
|
10
|
+
* MCP registration is the common case, and the editor that spawns it starts it wherever it likes.
|
|
11
|
+
*
|
|
12
|
+
* Three field shapes, one defect: `cwd=/` gave `ENOENT: mkdir '/.reticle'`; a daemon started in
|
|
13
|
+
* project A wrote project B's flow into A's checkout and reported success without naming the path;
|
|
14
|
+
* and `verify_change` could only answer "unknown" because no flow could be persisted for it to match
|
|
15
|
+
* against.
|
|
16
|
+
*
|
|
17
|
+
* ## Two sources, one rule
|
|
18
|
+
*
|
|
19
|
+
* Candidates arrive from config discovery (which walks out from the daemon's own directory) and from
|
|
20
|
+
* the user-level project registry (which `init` writes, and which reaches checkouts discovery cannot
|
|
21
|
+
* see). This function takes the merged list and does not know or care which source an entry came
|
|
22
|
+
* from — a privileged source would be a second rule, and two rules for one question is how the
|
|
23
|
+
* answers start disagreeing.
|
|
24
|
+
*
|
|
25
|
+
* ## Why matching on projectId, and not a new wire field
|
|
26
|
+
*
|
|
27
|
+
* HELLO already stamps `projectId`, and `.reticle.json` already declares the same id sitting next to
|
|
28
|
+
* the code it configures. The join is therefore available today, for every SDK already in the field.
|
|
29
|
+
* Adding a `root` to HELLO would move the wire contract, strand every older SDK on the fallback, and
|
|
30
|
+
* buy nothing this does not already give.
|
|
31
|
+
*
|
|
32
|
+
* ## Why it can decline to answer
|
|
33
|
+
*
|
|
34
|
+
* Every non-matching branch returns the daemon root and SAYS which branch it was. A wrong root is
|
|
35
|
+
* worse than no root — it writes a caller's evidence into a tree they never drove — so the ambiguous
|
|
36
|
+
* case refuses rather than picks, exactly as `discoverProjectConfigs` refuses to pick one config.
|
|
37
|
+
* The reason travels with the answer so callers can report the surprise instead of hiding it.
|
|
38
|
+
*/
|
|
39
|
+
/** Why the root below is the root. Travels with the answer so a caller can say what happened. */
|
|
40
|
+
export declare const ArtifactRootReason: {
|
|
41
|
+
/** Exactly one discovered config declares this session's project. The good case. */
|
|
42
|
+
readonly MATCHED_PROJECT: "matched-project";
|
|
43
|
+
/** The session declared no projectId — a pre-2.0 SDK. Nothing to match on. */
|
|
44
|
+
readonly NO_PROJECT_ID: "no-project-id";
|
|
45
|
+
/** The search ran and nothing it found declares this project. */
|
|
46
|
+
readonly NO_MATCH: "no-match";
|
|
47
|
+
/** Two or more checkouts declare this project. Refused rather than guessed. */
|
|
48
|
+
readonly AMBIGUOUS: "ambiguous";
|
|
49
|
+
};
|
|
50
|
+
export type ArtifactRootReason = (typeof ArtifactRootReason)[keyof typeof ArtifactRootReason];
|
|
51
|
+
export interface ArtifactRootQuery {
|
|
52
|
+
/** The connected session's HELLO projectId, when it sent one. */
|
|
53
|
+
projectId: string | undefined;
|
|
54
|
+
/** Every project this machine knows about. Supplied, not gathered here — this stays pure. */
|
|
55
|
+
candidates: readonly ProjectCandidate[];
|
|
56
|
+
/** Where artifacts go when the project cannot be identified. Already a `.reticle` path. */
|
|
57
|
+
daemonRoot: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ArtifactRoot {
|
|
60
|
+
/** Absolute path to the `.reticle` directory. Always present, on every branch. */
|
|
61
|
+
root: string;
|
|
62
|
+
reason: ArtifactRootReason;
|
|
63
|
+
/**
|
|
64
|
+
* The competing project directories, on `AMBIGUOUS` only. A caller that has to explain the refusal
|
|
65
|
+
* needs to name them; a caller that does not can ignore the field.
|
|
66
|
+
*/
|
|
67
|
+
candidates?: string[];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the artifact root for one session.
|
|
71
|
+
*
|
|
72
|
+
* Pure: no IO, no cwd, no clock. The filesystem walk belongs to `discoverProjectConfigs` and is
|
|
73
|
+
* passed in, which is what makes every branch below testable without a fixture tree.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveArtifactRoot(query: ArtifactRootQuery): ArtifactRoot;
|
|
76
|
+
/**
|
|
77
|
+
* Everything this machine knows about where projects live, from both sources.
|
|
78
|
+
*
|
|
79
|
+
* Discovery first, deliberately: it read a `.reticle.json` that exists RIGHT NOW, while the registry
|
|
80
|
+
* is a cache of something `init` saw once. Order only decides which duplicate survives dedupe, and
|
|
81
|
+
* the one confirmed a moment ago is the better survivor.
|
|
82
|
+
*/
|
|
83
|
+
export declare function projectCandidatesFrom(discovery: ConfigDiscovery, registry: ProjectRegistry): ProjectCandidate[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { ReticleDir, projectCandidates, } from '@reticlehq/core';
|
|
3
|
+
/**
|
|
4
|
+
* Which `.reticle/` a session's artifacts belong in.
|
|
5
|
+
*
|
|
6
|
+
* Everything Reticle persists — flows, the capability contract, baselines, capsules, the cross-run
|
|
7
|
+
* project memory — used to resolve against the DAEMON's own `process.cwd()`. That is a statement
|
|
8
|
+
* about where the daemon was launched and it was being read as a statement about which project is
|
|
9
|
+
* being verified. Those are not the same thing and are usually not even the same tree: a user-scoped
|
|
10
|
+
* MCP registration is the common case, and the editor that spawns it starts it wherever it likes.
|
|
11
|
+
*
|
|
12
|
+
* Three field shapes, one defect: `cwd=/` gave `ENOENT: mkdir '/.reticle'`; a daemon started in
|
|
13
|
+
* project A wrote project B's flow into A's checkout and reported success without naming the path;
|
|
14
|
+
* and `verify_change` could only answer "unknown" because no flow could be persisted for it to match
|
|
15
|
+
* against.
|
|
16
|
+
*
|
|
17
|
+
* ## Two sources, one rule
|
|
18
|
+
*
|
|
19
|
+
* Candidates arrive from config discovery (which walks out from the daemon's own directory) and from
|
|
20
|
+
* the user-level project registry (which `init` writes, and which reaches checkouts discovery cannot
|
|
21
|
+
* see). This function takes the merged list and does not know or care which source an entry came
|
|
22
|
+
* from — a privileged source would be a second rule, and two rules for one question is how the
|
|
23
|
+
* answers start disagreeing.
|
|
24
|
+
*
|
|
25
|
+
* ## Why matching on projectId, and not a new wire field
|
|
26
|
+
*
|
|
27
|
+
* HELLO already stamps `projectId`, and `.reticle.json` already declares the same id sitting next to
|
|
28
|
+
* the code it configures. The join is therefore available today, for every SDK already in the field.
|
|
29
|
+
* Adding a `root` to HELLO would move the wire contract, strand every older SDK on the fallback, and
|
|
30
|
+
* buy nothing this does not already give.
|
|
31
|
+
*
|
|
32
|
+
* ## Why it can decline to answer
|
|
33
|
+
*
|
|
34
|
+
* Every non-matching branch returns the daemon root and SAYS which branch it was. A wrong root is
|
|
35
|
+
* worse than no root — it writes a caller's evidence into a tree they never drove — so the ambiguous
|
|
36
|
+
* case refuses rather than picks, exactly as `discoverProjectConfigs` refuses to pick one config.
|
|
37
|
+
* The reason travels with the answer so callers can report the surprise instead of hiding it.
|
|
38
|
+
*/
|
|
39
|
+
/** Why the root below is the root. Travels with the answer so a caller can say what happened. */
|
|
40
|
+
export const ArtifactRootReason = {
|
|
41
|
+
/** Exactly one discovered config declares this session's project. The good case. */
|
|
42
|
+
MATCHED_PROJECT: 'matched-project',
|
|
43
|
+
/** The session declared no projectId — a pre-2.0 SDK. Nothing to match on. */
|
|
44
|
+
NO_PROJECT_ID: 'no-project-id',
|
|
45
|
+
/** The search ran and nothing it found declares this project. */
|
|
46
|
+
NO_MATCH: 'no-match',
|
|
47
|
+
/** Two or more checkouts declare this project. Refused rather than guessed. */
|
|
48
|
+
AMBIGUOUS: 'ambiguous',
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the artifact root for one session.
|
|
52
|
+
*
|
|
53
|
+
* Pure: no IO, no cwd, no clock. The filesystem walk belongs to `discoverProjectConfigs` and is
|
|
54
|
+
* passed in, which is what makes every branch below testable without a fixture tree.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveArtifactRoot(query) {
|
|
57
|
+
const { projectId, candidates, daemonRoot } = query;
|
|
58
|
+
if (projectId === undefined || 0 === projectId.length) {
|
|
59
|
+
return { root: daemonRoot, reason: ArtifactRootReason.NO_PROJECT_ID };
|
|
60
|
+
}
|
|
61
|
+
const matches = dedupeByDirectory(candidates.filter((c) => c.projectId === projectId));
|
|
62
|
+
if (0 === matches.length) {
|
|
63
|
+
return { root: daemonRoot, reason: ArtifactRootReason.NO_MATCH };
|
|
64
|
+
}
|
|
65
|
+
if (matches.length > 1) {
|
|
66
|
+
return {
|
|
67
|
+
root: daemonRoot,
|
|
68
|
+
reason: ArtifactRootReason.AMBIGUOUS,
|
|
69
|
+
candidates: matches.map((config) => config.directory),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const [only] = matches;
|
|
73
|
+
// `matches.length === 1` above, so this is defined — but the codebase forbids `!`, and a default
|
|
74
|
+
// that can never be taken is cheaper than the alternative spelling.
|
|
75
|
+
const directory = only?.directory ?? '';
|
|
76
|
+
if (0 === directory.length) {
|
|
77
|
+
return { root: daemonRoot, reason: ArtifactRootReason.NO_MATCH };
|
|
78
|
+
}
|
|
79
|
+
return { root: join(directory, ReticleDir.ROOT), reason: ArtifactRootReason.MATCHED_PROJECT };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* One entry per directory.
|
|
83
|
+
*
|
|
84
|
+
* Discovery and the registry routinely name the SAME checkout — the registry remembers what `init`
|
|
85
|
+
* wrote, and discovery finds that same file whenever the daemon happens to be in the tree. Counting
|
|
86
|
+
* it twice would read as two competing checkouts and make the resolver refuse, which is the one
|
|
87
|
+
* outcome worse than either source alone.
|
|
88
|
+
*/
|
|
89
|
+
function dedupeByDirectory(matches) {
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const candidate of matches) {
|
|
93
|
+
if (seen.has(candidate.directory))
|
|
94
|
+
continue;
|
|
95
|
+
seen.add(candidate.directory);
|
|
96
|
+
out.push(candidate);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Everything this machine knows about where projects live, from both sources.
|
|
102
|
+
*
|
|
103
|
+
* Discovery first, deliberately: it read a `.reticle.json` that exists RIGHT NOW, while the registry
|
|
104
|
+
* is a cache of something `init` saw once. Order only decides which duplicate survives dedupe, and
|
|
105
|
+
* the one confirmed a moment ago is the better survivor.
|
|
106
|
+
*/
|
|
107
|
+
export function projectCandidatesFrom(discovery, registry) {
|
|
108
|
+
const discovered = discovery.found.flatMap((config) => config.projectId === undefined || 0 === config.projectId.length
|
|
109
|
+
? []
|
|
110
|
+
: [{ projectId: config.projectId, directory: config.directory }]);
|
|
111
|
+
return [...discovered, ...projectCandidates(registry)];
|
|
112
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FileSystemPort } from './fs-port.js';
|
|
2
|
+
export interface MemoryFs {
|
|
3
|
+
fs: FileSystemPort;
|
|
4
|
+
/** Every file written, keyed by normalised absolute path. */
|
|
5
|
+
written: Map<string, string>;
|
|
6
|
+
/** Every directory created. */
|
|
7
|
+
dirs: Set<string>;
|
|
8
|
+
}
|
|
9
|
+
export declare function createMemoryFs(): MemoryFs;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory `FileSystemPort`, and the map it wrote into.
|
|
3
|
+
*
|
|
4
|
+
* Three specs had hand-rolled their own copy of this before it was extracted, each one slightly
|
|
5
|
+
* different and each one a fresh chance to reintroduce the same fixture bug: the code under test
|
|
6
|
+
* joins paths with the PLATFORM separator, the fixtures are written with POSIX ones, and a spec that
|
|
7
|
+
* forgets to normalise passes on macOS and fails on Windows. Normalising at the port — once, here —
|
|
8
|
+
* is what stops that recurring.
|
|
9
|
+
*
|
|
10
|
+
* Exposing `written` is the other reason this exists. A store's return value says what it believes it
|
|
11
|
+
* did; the map says where the bytes actually went, which is the only way to test a defect about
|
|
12
|
+
* writing to the wrong directory.
|
|
13
|
+
*/
|
|
14
|
+
/** POSIX form, so a `\`-joined path and a `/`-joined one are the same key. */
|
|
15
|
+
const norm = (path) => path.split('\\').join('/');
|
|
16
|
+
export function createMemoryFs() {
|
|
17
|
+
const written = new Map();
|
|
18
|
+
const dirs = new Set();
|
|
19
|
+
const notFound = () => {
|
|
20
|
+
const err = new Error('ENOENT: no such file or directory');
|
|
21
|
+
err.code = 'ENOENT';
|
|
22
|
+
return err;
|
|
23
|
+
};
|
|
24
|
+
const fs = {
|
|
25
|
+
readFile(path) {
|
|
26
|
+
const value = written.get(norm(path));
|
|
27
|
+
return value === undefined ? Promise.reject(notFound()) : Promise.resolve(value);
|
|
28
|
+
},
|
|
29
|
+
writeFile(path, data) {
|
|
30
|
+
written.set(norm(path), data);
|
|
31
|
+
return Promise.resolve();
|
|
32
|
+
},
|
|
33
|
+
appendFile(path, data) {
|
|
34
|
+
written.set(norm(path), (written.get(norm(path)) ?? '') + data);
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
},
|
|
37
|
+
readFileBytes(path) {
|
|
38
|
+
const value = written.get(norm(path));
|
|
39
|
+
return value === undefined
|
|
40
|
+
? Promise.reject(notFound())
|
|
41
|
+
: Promise.resolve(new TextEncoder().encode(value));
|
|
42
|
+
},
|
|
43
|
+
writeFileBytes(path, data) {
|
|
44
|
+
written.set(norm(path), new TextDecoder().decode(data));
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
},
|
|
47
|
+
mkdir(path) {
|
|
48
|
+
dirs.add(norm(path));
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
},
|
|
51
|
+
exists(path) {
|
|
52
|
+
const key = norm(path);
|
|
53
|
+
return Promise.resolve(written.has(key) || dirs.has(key));
|
|
54
|
+
},
|
|
55
|
+
readdir(path) {
|
|
56
|
+
const prefix = `${norm(path)}/`;
|
|
57
|
+
const names = new Set();
|
|
58
|
+
for (const key of written.keys()) {
|
|
59
|
+
if (key.startsWith(prefix)) {
|
|
60
|
+
const rest = key.slice(prefix.length).split('/')[0];
|
|
61
|
+
if (rest !== undefined && rest.length > 0)
|
|
62
|
+
names.add(rest);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Promise.resolve([...names]);
|
|
66
|
+
},
|
|
67
|
+
rename(from, to) {
|
|
68
|
+
const value = written.get(norm(from));
|
|
69
|
+
if (value === undefined)
|
|
70
|
+
return Promise.reject(notFound());
|
|
71
|
+
written.set(norm(to), value);
|
|
72
|
+
written.delete(norm(from));
|
|
73
|
+
return Promise.resolve();
|
|
74
|
+
},
|
|
75
|
+
rm(path) {
|
|
76
|
+
written.delete(norm(path));
|
|
77
|
+
dirs.delete(norm(path));
|
|
78
|
+
return Promise.resolve();
|
|
79
|
+
},
|
|
80
|
+
stat(path) {
|
|
81
|
+
return written.has(norm(path)) ? Promise.resolve({ mtimeMs: 0 }) : Promise.reject(notFound());
|
|
82
|
+
},
|
|
83
|
+
isNotFound(error) {
|
|
84
|
+
return 'ENOENT' === error?.code;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
return { fs, written, dirs };
|
|
88
|
+
}
|
|
@@ -32,6 +32,23 @@ export declare class ProjectStore {
|
|
|
32
32
|
* concurrent browser sessions accumulate rather than overwriting one another.
|
|
33
33
|
*/
|
|
34
34
|
recordRoutes(routes: readonly string[]): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* The best observability this project has reached, and the raiser of that bar.
|
|
37
|
+
*
|
|
38
|
+
* Read returns undefined on a first run or an unreadable file, which is the honest "no best to
|
|
39
|
+
* fall from" — never a zero, which would make every first run look like a recovery.
|
|
40
|
+
*/
|
|
41
|
+
bestObservability(): Promise<{
|
|
42
|
+
percent: number;
|
|
43
|
+
} | undefined>;
|
|
44
|
+
/**
|
|
45
|
+
* Raise the bar, if this run cleared it. Lowering is deliberately impossible.
|
|
46
|
+
*
|
|
47
|
+
* A best that could fall would let a weakened run quietly redefine what good looks like, which is
|
|
48
|
+
* precisely the gaming the figure exists to resist. Serialized per file so two sessions racing
|
|
49
|
+
* cannot lose the higher of the two.
|
|
50
|
+
*/
|
|
51
|
+
raiseObservability(percent: number): Promise<void>;
|
|
35
52
|
/** The most-recent run for `name` (undefined on missing/malformed/none). Powers diff-vs-last. */
|
|
36
53
|
lastRun(name: string): Promise<RunRecord | undefined>;
|
|
37
54
|
}
|
|
@@ -101,6 +101,39 @@ export class ProjectStore {
|
|
|
101
101
|
await this.#fs.writeFile(path, this.#serialize(next));
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* The best observability this project has reached, and the raiser of that bar.
|
|
106
|
+
*
|
|
107
|
+
* Read returns undefined on a first run or an unreadable file, which is the honest "no best to
|
|
108
|
+
* fall from" — never a zero, which would make every first run look like a recovery.
|
|
109
|
+
*/
|
|
110
|
+
async bestObservability() {
|
|
111
|
+
const existing = await this.read();
|
|
112
|
+
return existing.ok ? existing.file.learned?.bestObservability : undefined;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Raise the bar, if this run cleared it. Lowering is deliberately impossible.
|
|
116
|
+
*
|
|
117
|
+
* A best that could fall would let a weakened run quietly redefine what good looks like, which is
|
|
118
|
+
* precisely the gaming the figure exists to resist. Serialized per file so two sessions racing
|
|
119
|
+
* cannot lose the higher of the two.
|
|
120
|
+
*/
|
|
121
|
+
async raiseObservability(percent) {
|
|
122
|
+
const path = reticleDirPaths(this.#root).project;
|
|
123
|
+
await withFileLock(path, async () => {
|
|
124
|
+
const existing = await this.read();
|
|
125
|
+
const base = existing.ok ? existing.file : EMPTY_PROJECT;
|
|
126
|
+
const current = base.learned?.bestObservability?.percent;
|
|
127
|
+
if (current !== undefined && percent <= current)
|
|
128
|
+
return;
|
|
129
|
+
const learned = {
|
|
130
|
+
...base.learned,
|
|
131
|
+
bestObservability: { percent, at: this.#clock.now() },
|
|
132
|
+
};
|
|
133
|
+
await this.#fs.mkdir(reticleDirPaths(this.#root).root);
|
|
134
|
+
await this.#fs.writeFile(path, this.#serialize({ ...base, learned }));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
104
137
|
/** The most-recent run for `name` (undefined on missing/malformed/none). Powers diff-vs-last. */
|
|
105
138
|
async lastRun(name) {
|
|
106
139
|
const read = await this.read();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record where this project lives, so a daemon started somewhere else can still find it.
|
|
3
|
+
*
|
|
4
|
+
* Called at the end of `init`, which is the one moment both halves are known for certain: the
|
|
5
|
+
* directory being initialised, and the projectId that was just written into its `.reticle.json`.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately runs on EVERY init, not only the first. Most init runs report "already wired", and a
|
|
8
|
+
* re-run in a re-cloned or moved checkout is exactly when the remembered path has gone stale — so
|
|
9
|
+
* the run that would have been a no-op is the one that repairs the entry.
|
|
10
|
+
*
|
|
11
|
+
* Every failure is swallowed. This is a cache that makes a later resolution better; a read-only home
|
|
12
|
+
* directory, a full disk or a hand-mangled file must not turn a successful init into a failed one.
|
|
13
|
+
* The user's project is wired either way, and `init`'s report is the first thing a new user reads.
|
|
14
|
+
*/
|
|
15
|
+
/** The sync filesystem seam `init` already carries. Narrowed to what this needs. */
|
|
16
|
+
export interface RegistryIo {
|
|
17
|
+
readFile(path: string): string | null;
|
|
18
|
+
writeFile(path: string, content: string): void;
|
|
19
|
+
exists(path: string): boolean;
|
|
20
|
+
homeDir(): string;
|
|
21
|
+
}
|
|
22
|
+
/** The `projectId` a `.reticle.json` declares, or undefined when it declares none we can use. */
|
|
23
|
+
export declare function projectIdOf(configSource: string | null): string | undefined;
|
|
24
|
+
export declare function rememberProjectOnDisk(io: RegistryIo, projectId: string, directory: string, now: number): boolean;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { PROJECT_REGISTRY_FILE, ReticleDir, emptyProjectRegistry, parseProjectRegistry, rememberProject, } from '@reticlehq/core';
|
|
3
|
+
/** The `projectId` a `.reticle.json` declares, or undefined when it declares none we can use. */
|
|
4
|
+
export function projectIdOf(configSource) {
|
|
5
|
+
if (null === configSource || 0 === configSource.length)
|
|
6
|
+
return undefined;
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(configSource);
|
|
9
|
+
if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed))
|
|
10
|
+
return undefined;
|
|
11
|
+
const id = parsed['projectId'];
|
|
12
|
+
return 'string' === typeof id && id.length > 0 ? id : undefined;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function rememberProjectOnDisk(io, projectId, directory, now) {
|
|
19
|
+
if (0 === projectId.length || 0 === directory.length)
|
|
20
|
+
return false;
|
|
21
|
+
try {
|
|
22
|
+
const path = join(io.homeDir(), ReticleDir.ROOT, PROJECT_REGISTRY_FILE);
|
|
23
|
+
const existing = io.exists(path) ? io.readFile(path) : null;
|
|
24
|
+
const registry = null === existing ? emptyProjectRegistry() : parseProjectRegistry(safeJson(existing));
|
|
25
|
+
const next = rememberProject(registry, projectId, directory, now);
|
|
26
|
+
io.writeFile(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function safeJson(raw) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ToolDeps } from '../tools/tool-kit.js';
|
|
2
|
+
/**
|
|
3
|
+
* The `.reticle` directory a tool call's artifacts belong in.
|
|
4
|
+
*
|
|
5
|
+
* Every call site that reads or writes a project artifact goes through here, and that is the whole
|
|
6
|
+
* point of the function existing rather than each site asking `deps.artifactRootFor` itself. The
|
|
7
|
+
* write half and the read half MUST agree: a `flow_save` that resolves to the project while
|
|
8
|
+
* `verify_change` still reads the daemon's own directory would be a worse defect than the one being
|
|
9
|
+
* fixed — the flow would save successfully and then be invisible to the tool that exists to replay
|
|
10
|
+
* it, which reads as "no flows covered this change" rather than as an error.
|
|
11
|
+
*
|
|
12
|
+
* Falls back to `deps.reticleRoot` whenever resolution cannot name a project, which is exactly the
|
|
13
|
+
* behaviour every call site had before the resolver existed.
|
|
14
|
+
*/
|
|
15
|
+
export declare function sessionRoot(deps: ToolDeps, sessionId: string | undefined): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.reticle` directory a tool call's artifacts belong in.
|
|
3
|
+
*
|
|
4
|
+
* Every call site that reads or writes a project artifact goes through here, and that is the whole
|
|
5
|
+
* point of the function existing rather than each site asking `deps.artifactRootFor` itself. The
|
|
6
|
+
* write half and the read half MUST agree: a `flow_save` that resolves to the project while
|
|
7
|
+
* `verify_change` still reads the daemon's own directory would be a worse defect than the one being
|
|
8
|
+
* fixed — the flow would save successfully and then be invisible to the tool that exists to replay
|
|
9
|
+
* it, which reads as "no flows covered this change" rather than as an error.
|
|
10
|
+
*
|
|
11
|
+
* Falls back to `deps.reticleRoot` whenever resolution cannot name a project, which is exactly the
|
|
12
|
+
* behaviour every call site had before the resolver existed.
|
|
13
|
+
*/
|
|
14
|
+
export function sessionRoot(deps, sessionId) {
|
|
15
|
+
return deps.artifactRootFor?.(sessionProjectIdOf(deps, sessionId)).root ?? deps.reticleRoot;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The connected session's projectId, or undefined when there is no session to ask.
|
|
19
|
+
*
|
|
20
|
+
* `sessions.resolve` throws when nothing is connected, when the id names no session, and when
|
|
21
|
+
* several are connected and none was named. All three mean the same thing here — we cannot tell
|
|
22
|
+
* which project this call is about — and none of them is a reason to fail the caller's tool.
|
|
23
|
+
*/
|
|
24
|
+
function sessionProjectIdOf(deps, sessionId) {
|
|
25
|
+
try {
|
|
26
|
+
return deps.sessions.resolve(sessionId).projectId;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/dist/runs/run-tools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { sessionRoot } from '../project/session-root.js';
|
|
2
3
|
import { RunReadError } from '@reticlehq/core';
|
|
3
4
|
import { ReticleTool } from '../tools/tool-names.js';
|
|
4
5
|
import { asString } from '../tools/tools-helpers.js';
|
|
@@ -36,7 +37,7 @@ export const RUN_TOOLS = [
|
|
|
36
37
|
error: z.string().optional(),
|
|
37
38
|
},
|
|
38
39
|
handler: async (deps, args) => {
|
|
39
|
-
const store = new RunStore(deps.fs, deps
|
|
40
|
+
const store = new RunStore(deps.fs, sessionRoot(deps, asString(args['sessionId'])));
|
|
40
41
|
// format:"diff" is a whole-history operation (two most-recent runs), not a single-run read.
|
|
41
42
|
if ('diff' === asString(args['format'])) {
|
|
42
43
|
const pair = await store.latestTwo();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the self-serve escape hatch can actually be taken, said in the same breath as the offer.
|
|
3
|
+
*
|
|
4
|
+
* `reticle_lease` is what `status`, `doctor` and the no-session diagnosis all recommend when no tab
|
|
5
|
+
* is connected: it opens a browser Reticle drives itself, so the agent does not have to wait for a
|
|
6
|
+
* human. It is the right advice and it is the one thing that cannot work when Chromium is missing or
|
|
7
|
+
* sitting at a revision this Playwright will not use — and the command giving the advice is usually
|
|
8
|
+
* the command that just diagnosed exactly that.
|
|
9
|
+
*
|
|
10
|
+
* Reported from the field, verbatim in shape: `doctor` printed `chromium ✗ wrong revision — the
|
|
11
|
+
* bundled playwright wants chromium-1234; the cache holds chromium-1228`, and then offered the lease
|
|
12
|
+
* as the way out. The reporter followed it, and could not have succeeded.
|
|
13
|
+
*
|
|
14
|
+
* Two things this refuses to do:
|
|
15
|
+
*
|
|
16
|
+
* **It does not fire on an absent probe.** A probe that could not run is not evidence the browser is
|
|
17
|
+
* missing, and a caveat asserting otherwise would be the same overclaiming this file exists to stop.
|
|
18
|
+
*
|
|
19
|
+
* **It does not tell the agent to install the browser.** A ~150MB download on somebody else's
|
|
20
|
+
* machine is not an agent's call to make unprompted, so the message names the command and hands it
|
|
21
|
+
* to the human rather than implying the agent should just run it.
|
|
22
|
+
*/
|
|
23
|
+
/** What the caller learned about the browser. Gathered by the caller so this stays pure. */
|
|
24
|
+
export interface LeaseBrowserState {
|
|
25
|
+
/** Does the executable this Playwright wants actually exist on disk? */
|
|
26
|
+
exists: boolean;
|
|
27
|
+
/** The install command, already pinned to the bundled Playwright by `chromium-hint`. */
|
|
28
|
+
installCommand?: string | undefined;
|
|
29
|
+
/** The revision this Playwright wants, when the path names one. */
|
|
30
|
+
wantedRevision?: string | undefined;
|
|
31
|
+
/** Chromium revisions actually present, wanted or not. */
|
|
32
|
+
installedRevisions?: readonly string[] | undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The sentence to append to a lease recommendation, or undefined when the lease is fine.
|
|
36
|
+
*
|
|
37
|
+
* Undefined rather than an empty string on the healthy path: a caller composing a message should not
|
|
38
|
+
* have to filter blanks, and a healthy install must pay nothing at all for this check.
|
|
39
|
+
*/
|
|
40
|
+
export declare function leaseCaveat(state: LeaseBrowserState | undefined): string | undefined;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the self-serve escape hatch can actually be taken, said in the same breath as the offer.
|
|
3
|
+
*
|
|
4
|
+
* `reticle_lease` is what `status`, `doctor` and the no-session diagnosis all recommend when no tab
|
|
5
|
+
* is connected: it opens a browser Reticle drives itself, so the agent does not have to wait for a
|
|
6
|
+
* human. It is the right advice and it is the one thing that cannot work when Chromium is missing or
|
|
7
|
+
* sitting at a revision this Playwright will not use — and the command giving the advice is usually
|
|
8
|
+
* the command that just diagnosed exactly that.
|
|
9
|
+
*
|
|
10
|
+
* Reported from the field, verbatim in shape: `doctor` printed `chromium ✗ wrong revision — the
|
|
11
|
+
* bundled playwright wants chromium-1234; the cache holds chromium-1228`, and then offered the lease
|
|
12
|
+
* as the way out. The reporter followed it, and could not have succeeded.
|
|
13
|
+
*
|
|
14
|
+
* Two things this refuses to do:
|
|
15
|
+
*
|
|
16
|
+
* **It does not fire on an absent probe.** A probe that could not run is not evidence the browser is
|
|
17
|
+
* missing, and a caveat asserting otherwise would be the same overclaiming this file exists to stop.
|
|
18
|
+
*
|
|
19
|
+
* **It does not tell the agent to install the browser.** A ~150MB download on somebody else's
|
|
20
|
+
* machine is not an agent's call to make unprompted, so the message names the command and hands it
|
|
21
|
+
* to the human rather than implying the agent should just run it.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The sentence to append to a lease recommendation, or undefined when the lease is fine.
|
|
25
|
+
*
|
|
26
|
+
* Undefined rather than an empty string on the healthy path: a caller composing a message should not
|
|
27
|
+
* have to filter blanks, and a healthy install must pay nothing at all for this check.
|
|
28
|
+
*/
|
|
29
|
+
export function leaseCaveat(state) {
|
|
30
|
+
if (state === undefined || state.exists)
|
|
31
|
+
return undefined;
|
|
32
|
+
// Present-at-the-wrong-revision and absent-entirely have different fixes and different readings,
|
|
33
|
+
// and collapsing them is what made the reported loop unbreakable — "the check is broken" and
|
|
34
|
+
// "none of these count" look identical from a flat `missing`.
|
|
35
|
+
const installed = state.installedRevisions ?? [];
|
|
36
|
+
const mismatch = state.wantedRevision !== undefined && installed.length > 0
|
|
37
|
+
? ` This Playwright wants ${state.wantedRevision} and the cache holds ${installed.join(', ')}, so the builds already on this machine will not be used.`
|
|
38
|
+
: '';
|
|
39
|
+
const command = state.installCommand === undefined ? '' : ` The fix is: ${state.installCommand}.`;
|
|
40
|
+
return ('NOTE — reticle_lease cannot run here: it drives a Reticle-owned Chromium and that browser is ' +
|
|
41
|
+
`not available.${mismatch}${command} That download is the human's call, not something to run ` +
|
|
42
|
+
'unprompted on their machine, so hand this back rather than treating the lease as a way forward.');
|
|
43
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* Pure: everything it needs is passed in, so the probe that discovers listening ports stays out of
|
|
19
19
|
* the hot resolve() path and this stays unit-testable.
|
|
20
20
|
*/
|
|
21
|
+
import { type LeaseBrowserState } from './lease-availability.js';
|
|
21
22
|
export interface NoSessionFacts {
|
|
22
23
|
/** Whether ANY session has connected to this daemon since it booted. */
|
|
23
24
|
everConnected: boolean;
|
|
@@ -36,6 +37,15 @@ export interface NoSessionFacts {
|
|
|
36
37
|
slowListeners?: readonly number[];
|
|
37
38
|
/** The port this daemon is on — half of the mismatch the old message asked about. */
|
|
38
39
|
port: number;
|
|
40
|
+
/**
|
|
41
|
+
* What the caller knows about the Chromium a lease would drive.
|
|
42
|
+
*
|
|
43
|
+
* Every branch below offers `reticle_lease` as the way not to wait for a human. When that browser
|
|
44
|
+
* is missing or at a revision this Playwright will not use, the offer is the one thing that cannot
|
|
45
|
+
* help — and it was being made by the same output that had just diagnosed exactly that. Optional:
|
|
46
|
+
* absent means the caller did not probe, which is not evidence the browser is missing.
|
|
47
|
+
*/
|
|
48
|
+
leaseBrowser?: LeaseBrowserState | undefined;
|
|
39
49
|
/**
|
|
40
50
|
* The directory `initialized` was decided in, named in the message when it is known.
|
|
41
51
|
*
|
|
@@ -100,6 +110,13 @@ export interface NoSessionFacts {
|
|
|
100
110
|
* that a reader deserves to check it.
|
|
101
111
|
*/
|
|
102
112
|
searchedDirectories?: readonly string[];
|
|
113
|
+
/**
|
|
114
|
+
* How long this daemon has been running with no app connected, in milliseconds.
|
|
115
|
+
*
|
|
116
|
+
* Present only when the stall clock is active and no app has arrived. Used to surface "the
|
|
117
|
+
* install never finished" to the user-facing diagnosis rather than only to telemetry.
|
|
118
|
+
*/
|
|
119
|
+
daemonUpMs?: number;
|
|
103
120
|
}
|
|
104
121
|
/**
|
|
105
122
|
* Every branch ends with this, and `recoveryFor` keys on it to suppress the generic no-session
|