@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.
Files changed (73) hide show
  1. package/SKILL.md +47 -9
  2. package/dist/dev/stale-issue-guard.d.ts +43 -0
  3. package/dist/dev/stale-issue-guard.js +75 -0
  4. package/dist/domain/domain-tools.js +3 -2
  5. package/dist/events/predicate-asks.d.ts +17 -0
  6. package/dist/events/predicate-asks.js +26 -0
  7. package/dist/events/predicate-eval.js +211 -24
  8. package/dist/events/predicate-parse.js +9 -3
  9. package/dist/events/predicate-schema.d.ts +18 -0
  10. package/dist/events/predicate-schema.js +55 -0
  11. package/dist/flows/flow-tools.js +33 -4
  12. package/dist/flows/predicate-to-expect.js +8 -0
  13. package/dist/flows/verify-change-tools.js +5 -1
  14. package/dist/honesty/blind-spots.d.ts +9 -0
  15. package/dist/honesty/blind-spots.js +17 -4
  16. package/dist/honesty/gap-ledger.d.ts +52 -0
  17. package/dist/honesty/gap-ledger.js +63 -0
  18. package/dist/honesty/instrumentation-gaps.d.ts +55 -0
  19. package/dist/honesty/instrumentation-gaps.js +21 -0
  20. package/dist/honesty/observability.d.ts +37 -0
  21. package/dist/honesty/observability.js +40 -0
  22. package/dist/index.d.ts +0 -1
  23. package/dist/index.js +41 -0
  24. package/dist/init/node-io.js +3 -0
  25. package/dist/init/run.d.ts +18 -0
  26. package/dist/init/run.js +34 -3
  27. package/dist/init/slash-command.d.ts +1 -1
  28. package/dist/init/slash-command.js +33 -10
  29. package/dist/journal/envelope.d.ts +24 -24
  30. package/dist/license/license.d.ts +8 -2
  31. package/dist/license/license.js +10 -15
  32. package/dist/project/artifact-root.d.ts +83 -0
  33. package/dist/project/artifact-root.js +112 -0
  34. package/dist/project/memory-fs.d.ts +9 -0
  35. package/dist/project/memory-fs.js +88 -0
  36. package/dist/project/project-store.d.ts +17 -0
  37. package/dist/project/project-store.js +33 -0
  38. package/dist/project/remember-project.d.ts +24 -0
  39. package/dist/project/remember-project.js +40 -0
  40. package/dist/project/session-root.d.ts +15 -0
  41. package/dist/project/session-root.js +31 -0
  42. package/dist/runs/run-tools.js +2 -1
  43. package/dist/session/lease-availability.d.ts +40 -0
  44. package/dist/session/lease-availability.js +43 -0
  45. package/dist/session/no-session-diagnosis.d.ts +10 -0
  46. package/dist/session/no-session-diagnosis.js +19 -7
  47. package/dist/session/no-session-watch.js +1 -1
  48. package/dist/session/session.d.ts +9 -0
  49. package/dist/session/session.js +9 -0
  50. package/dist/session/stall-clock.d.ts +18 -0
  51. package/dist/session/stall-clock.js +48 -0
  52. package/dist/telemetry/daemon-telemetry.js +12 -23
  53. package/dist/telemetry/session-metrics.d.ts +1 -1
  54. package/dist/telemetry/session-metrics.js +1 -1
  55. package/dist/telemetry/telemetry.d.ts +12 -3
  56. package/dist/telemetry/telemetry.js +25 -5
  57. package/dist/tools/act-tools.js +34 -4
  58. package/dist/tools/assert-verdict.d.ts +2 -0
  59. package/dist/tools/assert-verdict.js +24 -1
  60. package/dist/tools/contract-tools.js +5 -3
  61. package/dist/tools/coverage-tools.js +33 -0
  62. package/dist/tools/observe-tools.js +8 -2
  63. package/dist/tools/tool-kit.d.ts +16 -1
  64. package/docs/README.md +1 -0
  65. package/docs/docs.json +2 -2
  66. package/docs/enterprise.md +3 -1
  67. package/docs/license-activation.mdx +145 -0
  68. package/docs/telemetry-contract.md +1 -12
  69. package/docs/telemetry-events.mdx +1 -34
  70. package/docs/telemetry.md +0 -1
  71. package/package.json +2 -2
  72. package/dist/telemetry/instrumentation-stall.d.ts +0 -57
  73. package/dist/telemetry/instrumentation-stall.js +0 -103
@@ -0,0 +1,145 @@
1
+ ---
2
+ title: 'Activating a license key'
3
+ description: 'Set your key, confirm it worked, and fix it when it did not. Everything an activation needs, with no account and no call.'
4
+ icon: key
5
+ ---
6
+
7
+ If your organisation has a Reticle license key, this page is everything you need to put it in place. There is no account to create, no login, and no call with us required.
8
+
9
+ Do not have a key? Write to [hey@reticle.sh](mailto:hey@reticle.sh).
10
+
11
+ ## One key per organisation
12
+
13
+ A key covers your whole organisation. Every developer and every machine uses the same one, so share it the way you would share any other build-time credential: your secrets manager, your onboarding doc, your CI configuration.
14
+
15
+ ## Set it
16
+
17
+ Reticle reads one environment variable, `RETICLE_LICENSE_KEY`. Set it everywhere Reticle runs, which is usually more than one place.
18
+
19
+ <CodeGroup>
20
+
21
+ ```bash macOS / Linux
22
+ # In ~/.zshrc, ~/.bashrc, or your team's equivalent.
23
+ export RETICLE_LICENSE_KEY="your-key-here"
24
+ ```
25
+
26
+ ```powershell Windows
27
+ [Environment]::SetEnvironmentVariable('RETICLE_LICENSE_KEY', 'your-key-here', 'User')
28
+ ```
29
+
30
+ ```yaml GitHub Actions
31
+ env:
32
+ RETICLE_LICENSE_KEY: ${{ secrets.RETICLE_LICENSE_KEY }}
33
+ ```
34
+
35
+ ```bash Docker
36
+ docker run -e RETICLE_LICENSE_KEY="your-key-here" your-image
37
+ ```
38
+
39
+ </CodeGroup>
40
+
41
+ An export only affects shells started after it, so open a new terminal or `source` your profile.
42
+
43
+ ### Editors and agents that launch Reticle themselves
44
+
45
+ <Warning>
46
+ This is the step teams most often miss. Claude Desktop, Cursor and similar tools launch from the
47
+ desktop rather than from a terminal, so they never read your shell profile. An `export` that
48
+ plainly works in your terminal is invisible to them, and Reticle reports itself unlicensed.
49
+ </Warning>
50
+
51
+ Put the key in the MCP server's own `env` block instead, then restart the editor. Configuration is read at startup.
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "reticle": {
57
+ "command": "npx",
58
+ "args": ["@reticlehq/server", "mcp"],
59
+ "env": {
60
+ "RETICLE_LICENSE_KEY": "your-key-here"
61
+ }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ## Confirm it worked
68
+
69
+ ```bash
70
+ npx @reticlehq/server license
71
+ ```
72
+
73
+ You do not need `NODE_ENV` set, a project, a dev server, or a running app. This reads the key and nothing else.
74
+
75
+ | `status` | What it means | What to do |
76
+ | --- | --- | --- |
77
+ | `active` | Valid and in date. | Nothing. You are done. |
78
+ | `missing` | Reticle found no key in its environment. | The variable is not visible to this process. See below. |
79
+ | `expired` | The key was valid and its term has ended. | Contact us to renew. |
80
+ | `invalid` | The key was not accepted. | Usually a copy or paste problem. See below. |
81
+ | `eval` | This build has no licensing configured. | You are on a build older than licensing. Update. |
82
+
83
+ ## Over time
84
+
85
+ Your key carries an expiry, shown in the `license` output. A renewal is a new key for the same organisation: replace the value everywhere you set it, and nothing else changes.
86
+
87
+ Nothing breaks without warning. An expired key reports `expired` rather than failing silently, and you can check at any time with the command above.
88
+
89
+ Activation is **offline**. Reticle verifies your key cryptographically on your own machine, so there is no license server to reach. It works behind a firewall, on an air-gapped machine, and during any outage on our side.
90
+
91
+ ## When it does not work
92
+
93
+ <AccordionGroup>
94
+
95
+ <Accordion title="It says missing, but the variable is definitely set">
96
+ The commonest cause by a distance is that Reticle is being launched by something that does not
97
+ inherit your shell environment: an editor, a desktop agent, a systemd unit, a container. Run
98
+ `echo $RETICLE_LICENSE_KEY` in the same context Reticle runs in, not just in your own terminal. If
99
+ an editor launches it, use the MCP `env` block above.
100
+
101
+ Second commonest: the variable was set in a shell that was already open. Environment variables are read when a process starts, so open a new terminal and restart anything long-running.
102
+
103
+ </Accordion>
104
+
105
+ <Accordion title="It says invalid">
106
+ The key changed between us sending it and Reticle reading it. Almost always one of:
107
+
108
+ - truncated, most often by a line wrap when pasted into a config file or a chat client
109
+ - a character dropped or added while copying by hand
110
+ - quoted in a way that swallowed a character
111
+
112
+ Surrounding whitespace and a trailing newline are tolerated, so neither is the cause. Copy the key again in one piece. If it still reports `invalid`, write to us and we will re-issue it.
113
+
114
+ </Accordion>
115
+
116
+ <Accordion title="command not found: reticle">
117
+ Use the `npx @reticlehq/server ...` form shown on this page. It runs without a global install, and
118
+ it is the form we support.
119
+ </Accordion>
120
+
121
+ </AccordionGroup>
122
+
123
+ ## What a licensed deployment reports
124
+
125
+ Stated plainly, because a security review will ask.
126
+
127
+ Activation itself makes **no network request**. Your key is verified on your machine with public key cryptography.
128
+
129
+ Separately, Reticle sends anonymous usage metrics, as it does for every user. On a licensed deployment these carry your **license id**, an opaque identifier that means nothing without our own records, so we can tell your organisation's usage apart from everyone else's and know when to talk to you about renewal.
130
+
131
+ Never sent, licensed or not: your source code, your application's data, your users' data, URLs, selectors, page content, file contents, or your organisation's name.
132
+
133
+ The kill switch has no exception for licensed installations:
134
+
135
+ ```bash
136
+ export RETICLE_TELEMETRY=0
137
+ ```
138
+
139
+ `DO_NOT_TRACK=1` is honoured identically. If your policy forbids outbound telemetry, set it. Your activation is unaffected, because activation never needed the network. To keep your own usage data without sending it, `RETICLE_TELEMETRY_FILE=/path/to/file.jsonl` records the same stream locally and sends nothing.
140
+
141
+ ## Getting help
142
+
143
+ [hey@reticle.sh](mailto:hey@reticle.sh) for activation problems, renewals and re-issues.
144
+
145
+ Include the output of `npx @reticlehq/server license`. It carries your license id and no secrets, and it usually tells us the answer immediately.
@@ -103,7 +103,6 @@ The single exception is `daemon_stopped`, which is **awaited**, because the proc
103
103
  | `identified` | `reticle identify` | joins anonymous machine ids to a person who volunteered one |
104
104
  | `mcp_client_connected` | an MCP client attached | how many sessions are agent-driven at all |
105
105
  | `app_instrumented` | the first app carrying the SDK reached this daemon | **the funnel step everything turns on**; see below |
106
- | `instrumentation_stalled` | the daemon waited and no app ever arrived | the same funnel step, seen from the failure side; see below |
107
106
  | `mcp_connection_lost` | the proxy lost its daemon | **the transport-stability metric.** The disconnect that makes a user reopen `/mcp` is invisible without it |
108
107
  | `init_completed` | `reticle init` finished | does install actually work, outside the fixtures gate |
109
108
  | `bug_found` | a defect was detected in the app under test | the value delivered, as opposed to the work done |
@@ -117,17 +116,7 @@ Nothing measured the second. `daemon_started` and `mcp_client_connected` describ
117
116
 
118
117
  `app_instrumented` fires **once per daemon run**, on the first session-ready only, so `daemon_started` → `app_instrumented` is a rate rather than an inference and a reloading page cannot inflate it. It carries `initialized` (had `init` run here), `agentAttached` (was an agent already waiting), and `msToFirstApp` (how long the daemon sat with nothing wired). It deliberately carries no stack and no framework: `project_profiled` already reports both for the same run, and the two join on `sessionId`.
119
118
 
120
- ### The counterpart: `instrumentation_stalled`
121
-
122
- `app_instrumented` fires on success and only on success, so the larger population, runs where no app ever arrives, exists in the data as an **absence**. An absence is the one thing telemetry cannot interpret: it cannot separate "nothing was ever wired" from "the process was killed before it could say so" from "we never sent the event". Since the funnel breaks at this step and nowhere else, this is the one silence worth converting into a signal.
123
-
124
- `instrumentation_stalled` fires **once per daemon run**, only after the daemon has been up past a generous threshold, and **never once an app has connected**. A run that starts slowly and then works reports nothing at all. It carries the same three facts as its success counterpart so the two are directly comparable, with `msWaited` in place of `msToFirstApp`.
125
-
126
- Read it against `agentAttached`, which is what makes it actionable rather than merely sad. A stalled daemon with no agent attached is somebody who installed and walked away, and is not a defect. A stalled daemon **with** an agent attached is the failure the product actually has: the tools are loaded, something is asking for them, and there is nothing on the other end to drive.
127
-
128
- It deliberately carries no diagnosis. Whether a dev server is listening is a live probe, and running one on a timer to enrich a metric would be collecting for the metric's sake. The diagnosis belongs where a human or an agent asks for it.
129
-
130
- What neither event can see is **why** an app never connected. Every cause for that is page-side (the non-localhost gate, a port mismatch, a stale build, a dev server never restarted), where the daemon has no visibility. That needs the SDK to report its own refusals, and it is the next thing to build.
119
+ The failure side is a **set difference**, not an event: `daemon_started` minus `app_instrumented`, joined on `sessionId`. There used to be an `instrumentation_stalled` event for it, and it was removed because the derivation is strictly more complete. The event could only fire on a flush tick or a graceful shutdown, so a killed daemon never produced one and landed in the absence anyway, while the set difference catches it. Two ways to compute one number, disagreeing with each other, is worse than one way.
131
120
 
132
121
  ## Sessions: `daemon_stopped` vs `session_progress`
133
122
 
@@ -55,7 +55,7 @@ Plus `installSource`, which is not a block. It is resolved once per process and
55
55
 
56
56
  `isSessionScoped(kind)` decides whether `sessionId` is attached. Twelve kinds are session-scoped:
57
57
 
58
- `daemon_started`, `daemon_stopped`, `session_progress`, `mcp_client_connected`, `app_instrumented`, `instrumentation_stalled`, `project_profiled`, `verification_completed`, `bug_found`, `tool_refused`, `runtime_crashed`, `feedback_submitted`.
58
+ `daemon_started`, `daemon_stopped`, `session_progress`, `mcp_client_connected`, `app_instrumented`, `project_profiled`, `verification_completed`, `bug_found`, `tool_refused`, `runtime_crashed`, `feedback_submitted`.
59
59
 
60
60
  `mcp_connection_lost` is deliberately **not** on that list even though it feels session-shaped. It fires from the proxy process rather than the daemon, so it carries no `sessionId` and cannot be joined to a session at all.
61
61
 
@@ -341,39 +341,6 @@ Fired **once per daemon run**, on the first connect only, so `daemon_started` to
341
341
 
342
342
  ---
343
343
 
344
- ## instrumentation_stalled
345
-
346
- **Wire name:** `instrumentation_stalled`. **Emitted from:** `packages/server/src/telemetry/instrumentation-stall.ts:73`, called from `daemon-telemetry.ts:105`. **Session-scoped:** yes. **Payload block:** `stall`.
347
-
348
- The failure side of `app_instrumented`, and the reason the funnel has a denominator at all. A daemon that runs for ten minutes without a single SDK-carrying page is an install that half worked, and until this event existed that outcome was pure silence: indistinguishable from a machine nobody touched.
349
-
350
- | Field | Type | Meaning |
351
- | --- | --- | --- |
352
- | `initialized` | boolean | Has `init` run in this working directory (`readProjectId(cwd)` resolves). False means the daemon is running somewhere `init` never wrote a project id, which is usually a wrong-directory problem rather than a wiring one |
353
- | `agentAttached` | boolean | Was an agent connected at the moment the check ran. **This is the field that decides whether the row is a defect.** False means somebody installed and walked away, which is not a bug. True means an agent was sitting there with tools it could not use, which is the failure the product actually has |
354
- | `msWaited` | int | Milliseconds since the daemon started, from the clock marked at `daemon-telemetry.ts:84` |
355
-
356
- Deliberately the same three facts as `app_instrumented`, with `msWaited` standing in for `msToFirstApp`, so the success and failure rows are directly comparable without reshaping either.
357
-
358
- **When it fires:** on every flush tick, but only once all four guards at `instrumentation-stall.ts:64` pass: it has not already been reported this run, no app has ever connected, the clock was marked, and at least `STALL_AFTER_MS` (10 minutes) has elapsed. It is checked **before** the empty-window early return in `flushWindow`, which is not an accident: a stalled install produces an empty window by definition, so checking after the return would blind the event to the exact condition it exists to report.
359
-
360
- **The question it answers:** of the installs that never reach a verdict, how many had an agent present and waiting? That is the difference between a distribution problem and a wiring problem, and they need opposite responses.
361
-
362
- **What it is blind to:**
363
-
364
- - **Any diagnosis.** It never says whether a dev server is listening, because that is a live probe, and probing a user's machine to enrich a metric is collecting for the metric's sake.
365
- - **Slow starts.** A run that connects at minute eleven reports nothing at all, and neither does the recovery. The event answers "did this run stall", not "how long do installs take".
366
- - **The page side.** Every real cause (a non-localhost gate, a port mismatch, a stale build, a dev server never restarted) lives where the daemon cannot see. Attributing a stall needs the SDK to report its own refusals, and that is not built.
367
- - **Repeats.** Once per daemon run, so a user stuck for a week contributes one row per daemon start, not one per hour.
368
-
369
- <Warning>
370
- This kind has no live check in `apps/e2e/specs/telemetry-events-test.mjs`, and neither do
371
- `session_progress` and `app_instrumented`. Those three are the funnel, and the funnel is the part
372
- of the wire nothing fires end to end. Adding one is the highest-value thing left in this file.
373
- </Warning>
374
-
375
- ---
376
-
377
344
  ## mcp_connection_lost
378
345
 
379
346
  **Wire name:** `mcp_connection_lost`. **Emitted from:** `packages/server/src/mcp/mcp-outage.ts:53`. **Session-scoped:** no. **Payload block:** `outage`.
package/docs/telemetry.md CHANGED
@@ -31,7 +31,6 @@ Eighteen kinds of events, each a single small JSON object. This is the whole lis
31
31
  | `daemon_stopped` | The local daemon stops | A summary of the session; see below |
32
32
  | `session_progress` | Periodically, from a daemon that is still running | The same summary as `daemon_stopped`, marked `final: false`. It exists so a long session is not invisible until it exits |
33
33
  | `app_instrumented` | The first time your app's SDK connects in a given daemon run | Nothing beyond the common fields. It is the second half of the install funnel: registering the MCP server is one thing, getting the SDK into a running page is another |
34
- | `instrumentation_stalled` | Ten minutes into a daemon run in which your app has still never connected | The same three facts as `app_instrumented`, with how long it waited instead of how long it took. It fires at most once per daemon run, and a run that starts slowly and then works reports nothing at all. It carries no diagnosis: whether a dev server is listening would need a live probe, and probing your machine to enrich a metric is collecting for the metric's sake |
35
34
  | `verification_completed` | A verification produces a verdict | Whether it passed, whether Reticle refused to call a passing check verified, and **why** the verdict came out that way; see below |
36
35
  | `project_profiled` | Once per daemon start | The shape of the project; see below |
37
36
  | `version_changed` | You update or roll back | The two version numbers, and which direction |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/server",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Reticle bridge + MCP server. Hosts the browser WS endpoint and exposes MCP tools to the coding agent.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "@modelcontextprotocol/sdk": "^1.30.0",
23
23
  "ws": "^8.21.3",
24
24
  "zod": "^3.24.1",
25
- "@reticlehq/core": "2.10.0"
25
+ "@reticlehq/core": "2.11.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/pngjs": "^6.0.5",
@@ -1,57 +0,0 @@
1
- /**
2
- * When a daemon has waited long enough that no app is coming.
3
- *
4
- * `app_instrumented` fires on success and only on success, so the population that matters most —
5
- * daemons that never get an app wired at all — exists in the data only as an absence. An absence is
6
- * the one thing telemetry cannot interpret: it cannot separate "nothing was ever wired" from "the
7
- * process was killed before it could say so" from "we never sent the event". The funnel breaks at
8
- * exactly this step and nowhere else, which makes it the one silence worth converting into a signal.
9
- *
10
- * Once per daemon run, and never after an app has connected. A daemon that takes a while and then
11
- * works reports nothing at all: the question is "did this run ever get an app", and a run that did
12
- * is not stalled no matter how long it took.
13
- *
14
- * The threshold is a judgement, not a measurement, and it is deliberately generous. Someone running
15
- * `init` and then going to make coffee has not hit a defect, and calling that a stall would drown
16
- * the real signal in ordinary human latency.
17
- *
18
- * Read the result against `agentAttached`, which is what makes it actionable: a stalled daemon with
19
- * no agent is somebody who installed and walked away, and is not a defect. A stalled daemon WITH an
20
- * agent attached is the failure the product actually has — the tools are loaded, something is asking
21
- * for them, and there is nothing on the other end to drive.
22
- */
23
- /**
24
- * How long a daemon may sit with no app before it counts as stalled.
25
- *
26
- * Ten minutes sits well past setup latency — reading a doc, restarting a dev server, opening a tab —
27
- * and well short of the half-hour idle windows that dominate the field, so a stall is reported while
28
- * the session is still alive rather than inferred from its corpse.
29
- */
30
- export declare const STALL_AFTER_MS: number;
31
- /** Start the clock. Called from the same place the instrumentation clock is marked. */
32
- export declare function markStallClock(now: number): void;
33
- export interface StallFacts {
34
- /** Whether this project has a projectId stamped — i.e. `init` has run here. */
35
- initialized: boolean;
36
- /** Whether an MCP client is attached right now. */
37
- agentAttached: boolean;
38
- }
39
- /**
40
- * Report a stalled install, at most once per daemon run.
41
- *
42
- * Returns whether it emitted, so the caller's test can assert the once-only rule without reaching
43
- * into the telemetry client. Best-effort like every other metric here: it must never be the reason
44
- * a daemon misbehaves.
45
- */
46
- export declare function reportInstrumentationStall(facts: StallFacts, now?: () => number, opts?: {
47
- atShutdown?: boolean;
48
- }): boolean;
49
- /**
50
- * How long this daemon has waited with no app, or undefined if the question is moot.
51
- *
52
- * Moot means: the clock was never started, or an app already connected. Callers use this to
53
- * surface the stall to user-facing channels without coupling to the telemetry event itself.
54
- */
55
- export declare function stallUptime(now: number): number | undefined;
56
- /** Tests only. */
57
- export declare function resetInstrumentationStall(): void;
@@ -1,103 +0,0 @@
1
- /**
2
- * When a daemon has waited long enough that no app is coming.
3
- *
4
- * `app_instrumented` fires on success and only on success, so the population that matters most —
5
- * daemons that never get an app wired at all — exists in the data only as an absence. An absence is
6
- * the one thing telemetry cannot interpret: it cannot separate "nothing was ever wired" from "the
7
- * process was killed before it could say so" from "we never sent the event". The funnel breaks at
8
- * exactly this step and nowhere else, which makes it the one silence worth converting into a signal.
9
- *
10
- * Once per daemon run, and never after an app has connected. A daemon that takes a while and then
11
- * works reports nothing at all: the question is "did this run ever get an app", and a run that did
12
- * is not stalled no matter how long it took.
13
- *
14
- * The threshold is a judgement, not a measurement, and it is deliberately generous. Someone running
15
- * `init` and then going to make coffee has not hit a defect, and calling that a stall would drown
16
- * the real signal in ordinary human latency.
17
- *
18
- * Read the result against `agentAttached`, which is what makes it actionable: a stalled daemon with
19
- * no agent is somebody who installed and walked away, and is not a defect. A stalled daemon WITH an
20
- * agent attached is the failure the product actually has — the tools are loaded, something is asking
21
- * for them, and there is nothing on the other end to drive.
22
- */
23
- import { TelemetryEventKind } from '@reticlehq/core';
24
- import { getTelemetry } from './telemetry.js';
25
- import { appEverConnected } from './app-instrumented.js';
26
- /**
27
- * How long a daemon may sit with no app before it counts as stalled.
28
- *
29
- * Ten minutes sits well past setup latency — reading a doc, restarting a dev server, opening a tab —
30
- * and well short of the half-hour idle windows that dominate the field, so a stall is reported while
31
- * the session is still alive rather than inferred from its corpse.
32
- */
33
- export const STALL_AFTER_MS = 10 * 60 * 1000;
34
- let reported = false;
35
- let daemonStartedAt;
36
- /** Start the clock. Called from the same place the instrumentation clock is marked. */
37
- export function markStallClock(now) {
38
- daemonStartedAt = now;
39
- reported = false;
40
- }
41
- /**
42
- * Report a stalled install, at most once per daemon run.
43
- *
44
- * Returns whether it emitted, so the caller's test can assert the once-only rule without reaching
45
- * into the telemetry client. Best-effort like every other metric here: it must never be the reason
46
- * a daemon misbehaves.
47
- */
48
- export function reportInstrumentationStall(facts, now = () => Date.now(), opts = {}) {
49
- if (reported)
50
- return false;
51
- // An app arrived. Whatever this run was, it was not stalled.
52
- if (appEverConnected())
53
- return false;
54
- if (daemonStartedAt === undefined)
55
- return false;
56
- const waited = now() - daemonStartedAt;
57
- // The threshold is a guard against crying stall seconds after boot. At SHUTDOWN there is nothing
58
- // left to wait for: this daemon ran, no app ever connected, and it is now gone. That is a fact
59
- // rather than an inference, and it does not become truer at ten minutes than at five.
60
- //
61
- // Skipping it here is what makes the event able to see anything at all. An UNATTENDED daemon
62
- // idle-exits at DAEMON_IDLE_SHUTDOWN_MS (5 min), well short of STALL_AFTER_MS (10) — only an
63
- // attached one gets the six-times grace that reaches the threshold. So on the periodic path alone
64
- // `agentAttached` was forced to true on every row, and the bucket this event exists for —
65
- // installed it, walked away, never wired the app — could not appear in the data at all.
66
- //
67
- // `msWaited` still carries the duration, so a consumer that wants to exclude short runs can do it
68
- // on the data instead of being unable to see them.
69
- if (true !== opts.atShutdown && waited < STALL_AFTER_MS)
70
- return false;
71
- reported = true;
72
- try {
73
- void getTelemetry().emit(TelemetryEventKind.INSTRUMENTATION_STALLED, {
74
- stall: {
75
- initialized: facts.initialized,
76
- agentAttached: facts.agentAttached,
77
- msWaited: Math.max(0, waited),
78
- },
79
- });
80
- }
81
- catch {
82
- /* never let a metric interfere with a running daemon */
83
- }
84
- return true;
85
- }
86
- /**
87
- * How long this daemon has waited with no app, or undefined if the question is moot.
88
- *
89
- * Moot means: the clock was never started, or an app already connected. Callers use this to
90
- * surface the stall to user-facing channels without coupling to the telemetry event itself.
91
- */
92
- export function stallUptime(now) {
93
- if (daemonStartedAt === undefined)
94
- return undefined;
95
- if (appEverConnected())
96
- return undefined;
97
- return now - daemonStartedAt;
98
- }
99
- /** Tests only. */
100
- export function resetInstrumentationStall() {
101
- reported = false;
102
- daemonStartedAt = undefined;
103
- }