@reticlehq/server 2.10.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 +47 -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/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/flows/flow-tools.js +33 -4
- package/dist/flows/predicate-to-expect.js +8 -0
- package/dist/flows/verify-change-tools.js +5 -1
- 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/index.d.ts +0 -1
- package/dist/index.js +41 -0
- 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 +8 -2
- package/dist/license/license.js +10 -15
- 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 +10 -0
- package/dist/session/no-session-diagnosis.js +19 -7
- package/dist/session/no-session-watch.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 +12 -23
- 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 +25 -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/observe-tools.js +8 -2
- package/dist/tools/tool-kit.d.ts +16 -1
- package/docs/README.md +1 -0
- package/docs/docs.json +2 -2
- package/docs/enterprise.md +3 -1
- package/docs/license-activation.mdx +145 -0
- package/docs/telemetry-contract.md +1 -12
- package/docs/telemetry-events.mdx +1 -34
- package/docs/telemetry.md +0 -1
- package/package.json +2 -2
- package/dist/telemetry/instrumentation-stall.d.ts +0 -57
- package/dist/telemetry/instrumentation-stall.js +0 -103
|
@@ -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
|
*
|
|
@@ -18,8 +18,9 @@
|
|
|
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 { leaseCaveat } from './lease-availability.js';
|
|
21
22
|
import { DEV_SERVER_PORTS } from '../cli/cli-port.js';
|
|
22
|
-
import { STALL_AFTER_MS } from '
|
|
23
|
+
import { STALL_AFTER_MS } from './stall-clock.js';
|
|
23
24
|
/** The one framework whose most likely cause differs from every other framework's. */
|
|
24
25
|
const NUXT = 'nuxt';
|
|
25
26
|
/**
|
|
@@ -58,6 +59,17 @@ const RETRY = SELF_RECOVERING_MARKER;
|
|
|
58
59
|
const URL_THEN_LEASE = 'Once you have that URL, open it with reticle_lease {action:"acquire", url} rather than a shell ' +
|
|
59
60
|
'command — it needs no CLI on PATH (reach it with reticle_run {tool:"reticle_lease"} if it is ' +
|
|
60
61
|
'not advertised directly).';
|
|
62
|
+
/**
|
|
63
|
+
* The lease advice, with the reason it cannot be taken when that is the case.
|
|
64
|
+
*
|
|
65
|
+
* A function rather than a constant so the caveat cannot be forgotten at one of the six sites that
|
|
66
|
+
* offer the lease — which is exactly how the offer came to be made by the command that had already
|
|
67
|
+
* diagnosed the problem.
|
|
68
|
+
*/
|
|
69
|
+
function leaseAdvice(base, facts) {
|
|
70
|
+
const caveat = leaseCaveat(facts.leaseBrowser);
|
|
71
|
+
return caveat === undefined ? base : `${base} ${caveat}`;
|
|
72
|
+
}
|
|
61
73
|
const SELF_SERVE = 'You do not have to wait for the human: reticle_lease {action:"acquire", url} opens a browser ' +
|
|
62
74
|
'Reticle drives itself, and returns a sessionId you can use immediately (reach it with ' +
|
|
63
75
|
'reticle_run {tool:"reticle_lease"} if it is not advertised directly; release it when you finish).';
|
|
@@ -292,7 +304,7 @@ export function diagnoseNoSession(facts) {
|
|
|
292
304
|
}
|
|
293
305
|
return ('no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
|
|
294
306
|
'is correct. The tab was closed, navigated away, or hard-reloaded. Ask the human to reopen ' +
|
|
295
|
-
`the app (or run ${OPEN_CMD_BARE}), or reload the tab. ${SELF_SERVE} ${RETRY}`);
|
|
307
|
+
`the app (or run ${OPEN_CMD_BARE}), or reload the tab. ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
|
|
296
308
|
}
|
|
297
309
|
// Ranked ahead of every "we have never seen one" branch below, because it is the one fact that
|
|
298
310
|
// OUTRANKS them: those branches all reason from an absence, and this reasons from a recorded
|
|
@@ -303,7 +315,7 @@ export function diagnoseNoSession(facts) {
|
|
|
303
315
|
? `${unattributedListeners(listening)} If the dev server is not running, start it first ` +
|
|
304
316
|
'(the command is in `next_action`).'
|
|
305
317
|
: unattributedListeners(listening);
|
|
306
|
-
return `${RESTARTED_LEAD} ${OPEN_THE_APP} ${listeners} ${rankedCauses(facts)} ${SELF_SERVE} ${RETRY}`;
|
|
318
|
+
return `${RESTARTED_LEAD} ${OPEN_THE_APP} ${listeners} ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`;
|
|
307
319
|
}
|
|
308
320
|
// A config found elsewhere outranks every "you may have no SDK" branch below: those reason from
|
|
309
321
|
// an absence in ONE directory, and this is a file we read in another. Ranked here so the reader is
|
|
@@ -311,7 +323,7 @@ export function diagnoseNoSession(facts) {
|
|
|
311
323
|
if (!initialized && (facts.configsElsewhere ?? []).length > 0) {
|
|
312
324
|
return ('no browser session connected, and this daemon has never seen one. ' +
|
|
313
325
|
`${configsElsewhereClause(facts)} ${unattributedListeners(listening)} ${OPEN_THE_APP} ` +
|
|
314
|
-
`${rankedCauses(facts)} ${SELF_SERVE} ${RETRY}`);
|
|
326
|
+
`${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
|
|
315
327
|
}
|
|
316
328
|
if (0 === listening.length) {
|
|
317
329
|
// Lead with the stronger EVIDENCE, and do not overstate what it is.
|
|
@@ -341,7 +353,7 @@ export function diagnoseNoSession(facts) {
|
|
|
341
353
|
`file ${INIT_CMD} writes, so the app may carry no Reticle SDK — but check the app's ` +
|
|
342
354
|
'OWN directory before re-running `init`: in a monorepo the daemon often runs at the root ' +
|
|
343
355
|
'while the app lives in a subdirectory, and an app wired by the Vite or Babel plugin ' +
|
|
344
|
-
`carries the SDK without that file at all.${searchedClause(facts)} ${URL_THEN_LEASE} ${RETRY}`);
|
|
356
|
+
`carries the SDK without that file at all.${searchedClause(facts)} ${leaseAdvice(URL_THEN_LEASE, facts)} ${RETRY}`);
|
|
345
357
|
}
|
|
346
358
|
return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one. ${OPEN_THE_APP} ` +
|
|
347
359
|
'Nothing is listening on the ports Reticle scans ' +
|
|
@@ -359,7 +371,7 @@ export function diagnoseNoSession(facts) {
|
|
|
359
371
|
// Deliberately NOT offering reticle_lease here, and a test pins that: a lease opens a URL, and
|
|
360
372
|
// if nothing is listening there is nothing at any URL to open. Asking for the real one is the
|
|
361
373
|
// only move that can recover the :7699 case.
|
|
362
|
-
`the app IS running, ask the human for its URL rather than assuming it is down. ${URL_THEN_LEASE} ` +
|
|
374
|
+
`the app IS running, ask the human for its URL rather than assuming it is down. ${leaseAdvice(URL_THEN_LEASE, facts)} ` +
|
|
363
375
|
// Reachable only for a project that HAS been through `init` — the uninstrumented case is
|
|
364
376
|
// answered above, leading with that certainty instead of behind this scan's guess.
|
|
365
377
|
`${RETRY}`);
|
|
@@ -377,5 +389,5 @@ export function diagnoseNoSession(facts) {
|
|
|
377
389
|
return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one for this project, which is ` +
|
|
378
390
|
`wired for Reticle. ${OPEN_THE_APP} ${unattributedListeners(listening)} ` +
|
|
379
391
|
'If the page IS open and still does not appear, the app is wired and the SDK is not reaching ' +
|
|
380
|
-
`this daemon (on ${String(port)}). ${rankedCauses(facts)} ${SELF_SERVE} ${RETRY}`);
|
|
392
|
+
`this daemon (on ${String(port)}). ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
|
|
381
393
|
}
|
|
@@ -17,7 +17,7 @@ import { readProjectFramework, readProjectId, readProjectPort } from '../cli/cli
|
|
|
17
17
|
import { discoverProjectConfigs } from '../cli/config-discovery.js';
|
|
18
18
|
import { hasProjectConnectedBefore, rememberConnected } from './connection-memory.js';
|
|
19
19
|
import { reticleStateHome } from '../daemon/daemon.js';
|
|
20
|
-
import { stallUptime } from '
|
|
20
|
+
import { stallUptime } from './stall-clock.js';
|
|
21
21
|
/** Slow enough to be free, fast enough that a dev server started 15s ago is already reflected. */
|
|
22
22
|
const REFRESH_MS = 15_000;
|
|
23
23
|
/** The URL an auto-attach opens. `localhost` for the same reason the probe uses it: both stacks. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { WebSocket } from 'ws';
|
|
2
2
|
import type { ImpactSnapshot } from '@reticlehq/core';
|
|
3
3
|
import { LastAct } from './last-act.js';
|
|
4
|
+
import { GapLedger } from '../honesty/gap-ledger.js';
|
|
4
5
|
import { type PageRuntime } from './command-timeout.js';
|
|
5
6
|
import { type SessionHealth } from './session-health.js';
|
|
6
7
|
export type { SessionHealth };
|
|
@@ -111,6 +112,14 @@ export declare class Session {
|
|
|
111
112
|
* from it, and the split between those two tool calls is the whole reason it exists.
|
|
112
113
|
*/
|
|
113
114
|
readonly lastAct: LastAct;
|
|
115
|
+
/**
|
|
116
|
+
* What this app still cannot tell Reticle, as of the most recent verdict.
|
|
117
|
+
*
|
|
118
|
+
* Lives on the session because that is the scope the question belongs to: "am I done with this
|
|
119
|
+
* app" is a session question, and a gap closed halfway through must not follow the agent to the
|
|
120
|
+
* end of it.
|
|
121
|
+
*/
|
|
122
|
+
readonly gaps: GapLedger;
|
|
114
123
|
/**
|
|
115
124
|
* Stamp the wall-clock of the latest AGENT command (called whenever a tool resolves this session).
|
|
116
125
|
* If the reaper had auto-ended the session, a fresh command means the agent is alive again, so the
|
package/dist/session/session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { recordImpact } from '../impact/impact-recorder.js';
|
|
2
2
|
import { LastAct } from './last-act.js';
|
|
3
|
+
import { GapLedger } from '../honesty/gap-ledger.js';
|
|
3
4
|
import { commandTimeoutMessage } from './command-timeout.js';
|
|
4
5
|
import { readHealthEvent } from './session-health.js';
|
|
5
6
|
/** The HUD does not need 200 frames to watch a counter climb; the last state always lands. */
|
|
@@ -370,6 +371,14 @@ export class Session {
|
|
|
370
371
|
* from it, and the split between those two tool calls is the whole reason it exists.
|
|
371
372
|
*/
|
|
372
373
|
lastAct = new LastAct();
|
|
374
|
+
/**
|
|
375
|
+
* What this app still cannot tell Reticle, as of the most recent verdict.
|
|
376
|
+
*
|
|
377
|
+
* Lives on the session because that is the scope the question belongs to: "am I done with this
|
|
378
|
+
* app" is a session question, and a gap closed halfway through must not follow the agent to the
|
|
379
|
+
* end of it.
|
|
380
|
+
*/
|
|
381
|
+
gaps = new GapLedger();
|
|
373
382
|
// ── Server-authoritative liveness (immune to browser-tab throttling) ──────────────
|
|
374
383
|
/**
|
|
375
384
|
* Stamp the wall-clock of the latest AGENT command (called whenever a tool resolves this session).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a run with nothing connected is worth mentioning to a human.
|
|
3
|
+
*
|
|
4
|
+
* Ten minutes sits well past setup latency — reading a doc, restarting a dev server, opening a tab —
|
|
5
|
+
* and short enough that the warning still arrives while somebody is at the keyboard.
|
|
6
|
+
*/
|
|
7
|
+
export declare const STALL_AFTER_MS: number;
|
|
8
|
+
/** Start the clock, from the same place the instrumentation clock is marked. */
|
|
9
|
+
export declare function markStallClock(now: number): void;
|
|
10
|
+
/**
|
|
11
|
+
* How long this daemon has waited with no app, or undefined if the question is moot.
|
|
12
|
+
*
|
|
13
|
+
* Moot means the clock was never started, or an app already connected. A run that starts slowly and
|
|
14
|
+
* then works has nothing to report, which is why this returns undefined rather than a duration.
|
|
15
|
+
*/
|
|
16
|
+
export declare function stallUptime(now: number): number | undefined;
|
|
17
|
+
/** Tests only. */
|
|
18
|
+
export declare function resetStallClock(): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long this daemon has waited with no app connected.
|
|
3
|
+
*
|
|
4
|
+
* This is what survived `instrumentation_stalled`. The EVENT was removed: it claimed to separate
|
|
5
|
+
* "never wired" from "died before reporting" and did not, because it only fired on a flush tick or a
|
|
6
|
+
* graceful shutdown, so a killed daemon was invisible to it exactly as before. Everything it carried
|
|
7
|
+
* is derivable from `daemon_started` minus `app_instrumented`, joined on `sessionId` — and that set
|
|
8
|
+
* difference is strictly MORE complete, because it does catch the killed daemons. A second way to
|
|
9
|
+
* compute the same number that disagrees with the first is worse than not having it.
|
|
10
|
+
*
|
|
11
|
+
* The CLOCK is a different thing and stays. Two live features read it, neither of them a metric: the
|
|
12
|
+
* no-session diagnosis tells an agent how long this daemon has been waiting, and the daemon warns on
|
|
13
|
+
* stderr when a run passes the threshold with nothing connected. Both are help for somebody stuck
|
|
14
|
+
* mid-install, which is the moment they are least able to work it out themselves.
|
|
15
|
+
*
|
|
16
|
+
* It lives under `session/` rather than `telemetry/` now, beside the two files that read it. The old
|
|
17
|
+
* home is why the removal looked like it would take the diagnosis with it.
|
|
18
|
+
*/
|
|
19
|
+
import { appEverConnected } from '../telemetry/app-instrumented.js';
|
|
20
|
+
/**
|
|
21
|
+
* When a run with nothing connected is worth mentioning to a human.
|
|
22
|
+
*
|
|
23
|
+
* Ten minutes sits well past setup latency — reading a doc, restarting a dev server, opening a tab —
|
|
24
|
+
* and short enough that the warning still arrives while somebody is at the keyboard.
|
|
25
|
+
*/
|
|
26
|
+
export const STALL_AFTER_MS = 10 * 60 * 1000;
|
|
27
|
+
let daemonStartedAt;
|
|
28
|
+
/** Start the clock, from the same place the instrumentation clock is marked. */
|
|
29
|
+
export function markStallClock(now) {
|
|
30
|
+
daemonStartedAt = now;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* How long this daemon has waited with no app, or undefined if the question is moot.
|
|
34
|
+
*
|
|
35
|
+
* Moot means the clock was never started, or an app already connected. A run that starts slowly and
|
|
36
|
+
* then works has nothing to report, which is why this returns undefined rather than a duration.
|
|
37
|
+
*/
|
|
38
|
+
export function stallUptime(now) {
|
|
39
|
+
if (daemonStartedAt === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (appEverConnected())
|
|
42
|
+
return undefined;
|
|
43
|
+
return now - daemonStartedAt;
|
|
44
|
+
}
|
|
45
|
+
/** Tests only. */
|
|
46
|
+
export function resetStallClock() {
|
|
47
|
+
daemonStartedAt = undefined;
|
|
48
|
+
}
|