@descryy/mcp 0.1.2 → 0.3.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/dist/bin/descry-mcp.js +30 -2
- package/dist/bin/descry-mcp.js.map +1 -1
- package/dist/browser/driver.d.ts +236 -0
- package/dist/browser/driver.d.ts.map +1 -0
- package/dist/browser/driver.js +70 -0
- package/dist/browser/driver.js.map +1 -0
- package/dist/browser/evidence.d.ts +47 -0
- package/dist/browser/evidence.d.ts.map +1 -0
- package/dist/browser/evidence.js +55 -0
- package/dist/browser/evidence.js.map +1 -0
- package/dist/browser/fake-driver.d.ts +58 -0
- package/dist/browser/fake-driver.d.ts.map +1 -0
- package/dist/browser/fake-driver.js +161 -0
- package/dist/browser/fake-driver.js.map +1 -0
- package/dist/browser/graph-write.d.ts +86 -0
- package/dist/browser/graph-write.d.ts.map +1 -0
- package/dist/browser/graph-write.js +135 -0
- package/dist/browser/graph-write.js.map +1 -0
- package/dist/browser/playwright-driver.d.ts +65 -0
- package/dist/browser/playwright-driver.d.ts.map +1 -0
- package/dist/browser/playwright-driver.js +359 -0
- package/dist/browser/playwright-driver.js.map +1 -0
- package/dist/browser/provider.d.ts +49 -0
- package/dist/browser/provider.d.ts.map +1 -0
- package/dist/browser/provider.js +28 -0
- package/dist/browser/provider.js.map +1 -0
- package/dist/browser/registry.d.ts +182 -0
- package/dist/browser/registry.d.ts.map +1 -0
- package/dist/browser/registry.js +215 -0
- package/dist/browser/registry.js.map +1 -0
- package/dist/browser/scenario-resolve.d.ts +62 -0
- package/dist/browser/scenario-resolve.d.ts.map +1 -0
- package/dist/browser/scenario-resolve.js +127 -0
- package/dist/browser/scenario-resolve.js.map +1 -0
- package/dist/browser/scenario-runner.d.ts +113 -0
- package/dist/browser/scenario-runner.d.ts.map +1 -0
- package/dist/browser/scenario-runner.js +315 -0
- package/dist/browser/scenario-runner.js.map +1 -0
- package/dist/browser/stack-parser.d.ts +43 -0
- package/dist/browser/stack-parser.d.ts.map +1 -0
- package/dist/browser/stack-parser.js +112 -0
- package/dist/browser/stack-parser.js.map +1 -0
- package/dist/browser/tool-support.d.ts +113 -0
- package/dist/browser/tool-support.d.ts.map +1 -0
- package/dist/browser/tool-support.js +181 -0
- package/dist/browser/tool-support.js.map +1 -0
- package/dist/disclosure-ledger.d.ts +38 -0
- package/dist/disclosure-ledger.d.ts.map +1 -0
- package/dist/disclosure-ledger.js +40 -0
- package/dist/disclosure-ledger.js.map +1 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +12 -3
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +12 -3
- package/dist/protocol.js.map +1 -1
- package/dist/render.d.ts +121 -10
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +96 -15
- package/dist/render.js.map +1 -1
- package/dist/runtime-registry.d.ts +63 -0
- package/dist/runtime-registry.d.ts.map +1 -0
- package/dist/runtime-registry.js +131 -0
- package/dist/runtime-registry.js.map +1 -0
- package/dist/scenarios/index.d.ts +4 -0
- package/dist/scenarios/index.d.ts.map +1 -0
- package/dist/scenarios/index.js +4 -0
- package/dist/scenarios/index.js.map +1 -0
- package/dist/scenarios/parse.d.ts +32 -0
- package/dist/scenarios/parse.d.ts.map +1 -0
- package/dist/scenarios/parse.js +199 -0
- package/dist/scenarios/parse.js.map +1 -0
- package/dist/scenarios/scenario.d.ts +115 -0
- package/dist/scenarios/scenario.d.ts.map +1 -0
- package/dist/scenarios/scenario.js +41 -0
- package/dist/scenarios/scenario.js.map +1 -0
- package/dist/scenarios/storage.d.ts +51 -0
- package/dist/scenarios/storage.d.ts.map +1 -0
- package/dist/scenarios/storage.js +93 -0
- package/dist/scenarios/storage.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +11 -3
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts +142 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +266 -4
- package/dist/session.js.map +1 -1
- package/dist/tools/analyze.d.ts +41 -1
- package/dist/tools/analyze.d.ts.map +1 -1
- package/dist/tools/analyze.js +111 -9
- package/dist/tools/analyze.js.map +1 -1
- package/dist/tools/browser-click.d.ts +36 -0
- package/dist/tools/browser-click.d.ts.map +1 -0
- package/dist/tools/browser-click.js +117 -0
- package/dist/tools/browser-click.js.map +1 -0
- package/dist/tools/browser-close-session.d.ts +24 -0
- package/dist/tools/browser-close-session.d.ts.map +1 -0
- package/dist/tools/browser-close-session.js +63 -0
- package/dist/tools/browser-close-session.js.map +1 -0
- package/dist/tools/browser-fill.d.ts +49 -0
- package/dist/tools/browser-fill.d.ts.map +1 -0
- package/dist/tools/browser-fill.js +138 -0
- package/dist/tools/browser-fill.js.map +1 -0
- package/dist/tools/browser-navigate.d.ts +32 -0
- package/dist/tools/browser-navigate.d.ts.map +1 -0
- package/dist/tools/browser-navigate.js +103 -0
- package/dist/tools/browser-navigate.js.map +1 -0
- package/dist/tools/browser-run-scenario.d.ts +54 -0
- package/dist/tools/browser-run-scenario.d.ts.map +1 -0
- package/dist/tools/browser-run-scenario.js +228 -0
- package/dist/tools/browser-run-scenario.js.map +1 -0
- package/dist/tools/browser-save-scenario.d.ts +44 -0
- package/dist/tools/browser-save-scenario.d.ts.map +1 -0
- package/dist/tools/browser-save-scenario.js +298 -0
- package/dist/tools/browser-save-scenario.js.map +1 -0
- package/dist/tools/browser-snapshot.d.ts +60 -0
- package/dist/tools/browser-snapshot.d.ts.map +1 -0
- package/dist/tools/browser-snapshot.js +139 -0
- package/dist/tools/browser-snapshot.js.map +1 -0
- package/dist/tools/browser-start-session.d.ts +43 -0
- package/dist/tools/browser-start-session.d.ts.map +1 -0
- package/dist/tools/browser-start-session.js +272 -0
- package/dist/tools/browser-start-session.js.map +1 -0
- package/dist/tools/browser-type.d.ts +48 -0
- package/dist/tools/browser-type.d.ts.map +1 -0
- package/dist/tools/browser-type.js +136 -0
- package/dist/tools/browser-type.js.map +1 -0
- package/dist/tools/contracts.d.ts +15 -0
- package/dist/tools/contracts.d.ts.map +1 -1
- package/dist/tools/contracts.js +14 -3
- package/dist/tools/contracts.js.map +1 -1
- package/dist/tools/cross-pr.d.ts.map +1 -1
- package/dist/tools/cross-pr.js +1 -0
- package/dist/tools/cross-pr.js.map +1 -1
- package/dist/tools/git-diff.d.ts.map +1 -1
- package/dist/tools/git-diff.js +104 -4
- package/dist/tools/git-diff.js.map +1 -1
- package/dist/tools/git-history.d.ts.map +1 -1
- package/dist/tools/git-history.js +1 -0
- package/dist/tools/git-history.js.map +1 -1
- package/dist/tools/history.js +1 -1
- package/dist/tools/history.js.map +1 -1
- package/dist/tools/impact.d.ts +9 -0
- package/dist/tools/impact.d.ts.map +1 -1
- package/dist/tools/impact.js +4 -4
- package/dist/tools/impact.js.map +1 -1
- package/dist/tools/index.d.ts +12 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +31 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/kit.d.ts +11 -1
- package/dist/tools/kit.d.ts.map +1 -1
- package/dist/tools/kit.js +3 -0
- package/dist/tools/kit.js.map +1 -1
- package/dist/tools/link-workspace.d.ts.map +1 -1
- package/dist/tools/link-workspace.js +13 -1
- package/dist/tools/link-workspace.js.map +1 -1
- package/dist/tools/mark-incident.d.ts +69 -0
- package/dist/tools/mark-incident.d.ts.map +1 -0
- package/dist/tools/mark-incident.js +212 -0
- package/dist/tools/mark-incident.js.map +1 -0
- package/dist/tools/observe-runtime.d.ts +292 -0
- package/dist/tools/observe-runtime.d.ts.map +1 -0
- package/dist/tools/observe-runtime.js +1192 -0
- package/dist/tools/observe-runtime.js.map +1 -0
- package/dist/tools/observe-tests.d.ts +125 -0
- package/dist/tools/observe-tests.d.ts.map +1 -0
- package/dist/tools/observe-tests.js +313 -0
- package/dist/tools/observe-tests.js.map +1 -0
- package/dist/tools/pr-analysis.d.ts +71 -13
- package/dist/tools/pr-analysis.d.ts.map +1 -1
- package/dist/tools/pr-analysis.js +55 -12
- package/dist/tools/pr-analysis.js.map +1 -1
- package/dist/tools/pre-push.d.ts +77 -0
- package/dist/tools/pre-push.d.ts.map +1 -0
- package/dist/tools/pre-push.js +250 -0
- package/dist/tools/pre-push.js.map +1 -0
- package/dist/tools/propagation.d.ts.map +1 -1
- package/dist/tools/propagation.js +1 -0
- package/dist/tools/propagation.js.map +1 -1
- package/dist/tools/questions.d.ts.map +1 -1
- package/dist/tools/questions.js +101 -14
- package/dist/tools/questions.js.map +1 -1
- package/dist/tools/refusal-fetch.d.ts.map +1 -1
- package/dist/tools/refusal-fetch.js +1 -0
- package/dist/tools/refusal-fetch.js.map +1 -1
- package/dist/tools/runtime-incident.d.ts +92 -0
- package/dist/tools/runtime-incident.d.ts.map +1 -0
- package/dist/tools/runtime-incident.js +144 -0
- package/dist/tools/runtime-incident.js.map +1 -0
- package/dist/tools/scope.d.ts.map +1 -1
- package/dist/tools/scope.js +1 -0
- package/dist/tools/scope.js.map +1 -1
- package/dist/tools/similar-incidents.d.ts +13 -0
- package/dist/tools/similar-incidents.d.ts.map +1 -1
- package/dist/tools/similar-incidents.js +22 -8
- package/dist/tools/similar-incidents.js.map +1 -1
- package/dist/tools/verification-status.d.ts +22 -0
- package/dist/tools/verification-status.d.ts.map +1 -1
- package/dist/tools/verification-status.js +52 -5
- package/dist/tools/verification-status.js.map +1 -1
- package/dist/tools/verify-claim.d.ts.map +1 -1
- package/dist/tools/verify-claim.js +5 -0
- package/dist/tools/verify-claim.js.map +1 -1
- package/package.json +16 -4
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `observe_runtime` — boot or attach to a real application, watch it, and write
|
|
3
|
+
* what was witnessed into the graph as R4 facts.
|
|
4
|
+
*
|
|
5
|
+
* **The second tool that writes, and the first that writes something no
|
|
6
|
+
* re-index can reproduce.** `analyze` re-derives the graph from source already
|
|
7
|
+
* on disk, so nothing it writes is a new claim about the world. This one runs
|
|
8
|
+
* a real process and records what it saw happen, which is the one accuracy
|
|
9
|
+
* source architecture §11B.3 calls *"the core technical moat"* and the one a
|
|
10
|
+
* purely static tool is structurally unable to reach: *"a purely static
|
|
11
|
+
* code-graph tool is capped at R3 permanently. It has no runtime."*
|
|
12
|
+
*
|
|
13
|
+
* ## What this composes, and what it invents
|
|
14
|
+
*
|
|
15
|
+
* It invents no mechanism. Every stage already existed, gate-verified, in
|
|
16
|
+
* `descry-runtime`, and every one of them was dormant — the whole point of
|
|
17
|
+
* `DEC-NEXT-mcp-runtime-dependency-boundary-for-r4-evidence`, which measured
|
|
18
|
+
* that `applyRuntimeObservations` had **zero production callers anywhere**,
|
|
19
|
+
* not in `descry-desktop` and not in `descry-runtime`'s own pipeline. Four
|
|
20
|
+
* shipped components in a row, and this tool is the wire between them:
|
|
21
|
+
*
|
|
22
|
+
* 1. `runInstrumentedExecution` (`@descryy/runtime-orchestrator`) spawns or
|
|
23
|
+
* attaches the declared services, starts the adapter's collectors, drains
|
|
24
|
+
* them for a stated window, and writes every item through `EvidenceStore`.
|
|
25
|
+
* 2. `correlateExecution` (`@descryy/runtime-evidence-correlation`) resolves
|
|
26
|
+
* each evidence item to the graph node it is *about* — the resolve-then-
|
|
27
|
+
* attribute pass. This answers identity, not edges.
|
|
28
|
+
* 3. `confirmObservedFrontendCaller` (`@descryy/runtime-graph-correlator`)
|
|
29
|
+
* turns a captured call-site stack plus a resolved endpoint into a
|
|
30
|
+
* `RuntimeEdgeObservation`, and
|
|
31
|
+
* 4. calls `applyRuntimeObservations` (`@descryy/core`) with it, which
|
|
32
|
+
* promotes, mints or contradicts the edge and writes the EARNED ledger.
|
|
33
|
+
*
|
|
34
|
+
* ## Why stage 3 exists rather than deriving edges from stage 2 directly
|
|
35
|
+
*
|
|
36
|
+
* `uat-phase-1-bug-fixes.md` Phase 2 describes step 2 as producing
|
|
37
|
+
* `RuntimeEdgeObservation[]`. It does not, and the difference is load-bearing
|
|
38
|
+
* rather than pedantic: `correlateExecution` returns
|
|
39
|
+
* `CorrelationAttribution`s — *(evidenceId, graphNodeId)* pairs saying which
|
|
40
|
+
* single node an observation is about. An edge needs **two** endpoints and a
|
|
41
|
+
* witnessed relationship between them, and manufacturing one by pairing up
|
|
42
|
+
* attributions that happen to co-occur in the same run would mint edges from
|
|
43
|
+
* temporal coincidence. That is precisely the wrong-direction failure rule 2
|
|
44
|
+
* exists to prevent, arriving through the one mechanism built to make the
|
|
45
|
+
* graph *more* trustworthy.
|
|
46
|
+
*
|
|
47
|
+
* So the observation comes from the one shipped producer that can honestly
|
|
48
|
+
* make one: a captured stack naming the caller, against an endpoint the same
|
|
49
|
+
* observation named. Both endpoints come from the observation itself. Every
|
|
50
|
+
* other correlated item is reported in the counts and produces no edge, which
|
|
51
|
+
* is a disclosed gap rather than a silent one.
|
|
52
|
+
*
|
|
53
|
+
* ## What this closure can actually witness today — measured, not assumed
|
|
54
|
+
*
|
|
55
|
+
* The wire is complete, and what it can carry changed when the runtime packages
|
|
56
|
+
* were published at 0.1.0 and pinned here — so this paragraph is the record of
|
|
57
|
+
* a limit that was real and is now lifted, kept rather than deleted because the
|
|
58
|
+
* shape of it recurs.
|
|
59
|
+
*
|
|
60
|
+
* **It used to be that no producer of the required pair — HTTP evidence
|
|
61
|
+
* carrying a call-site stack — was in this server's closure.** The browser
|
|
62
|
+
* network collector never has been. The outbound-fetch instrumentation in
|
|
63
|
+
* `@descryy/runtime-external-service-observation` existed, was proven in
|
|
64
|
+
* `descry-runtime`, and was not installed here at all. So a run resolved both
|
|
65
|
+
* kinds of node and wrote no edge, and `STANDING_NOTES` said so on every call
|
|
66
|
+
* because "no edge was written" and "nothing here could have written one" are
|
|
67
|
+
* different statements.
|
|
68
|
+
*
|
|
69
|
+
* **That second producer is now in the closure**, transitively through
|
|
70
|
+
* `@descryy/runtime-orchestrator`, which applies the adapter's
|
|
71
|
+
* `outboundHttpLaunch()` between the interpreter and the script so the client
|
|
72
|
+
* is patched before any application code can capture an unpatched one. Measured
|
|
73
|
+
* end to end in `observe-runtime-outbound.conformance.test.ts`: two real
|
|
74
|
+
* processes, 17 `EXTERNAL_REQUEST` items alongside 18 `BACKEND_LOG`, one
|
|
75
|
+
* `USES_API` edge minted at R4, and the first `strongly supported` reply this
|
|
76
|
+
* server has produced.
|
|
77
|
+
*
|
|
78
|
+
* **Two limits remain, and they are stated rather than inferred from a zero.**
|
|
79
|
+
* An *attached* service is not launched by Descry, so the instrumentation
|
|
80
|
+
* cannot be installed into it and its outbound calls carry no stack. And
|
|
81
|
+
* browser-side traffic still needs `@descryy/runtime-browser`, which is not
|
|
82
|
+
* here. Both are in `STANDING_NOTES`.
|
|
83
|
+
*
|
|
84
|
+
* The rule that outlives all of it: a disclosure about the closure is a fact
|
|
85
|
+
* about *this build*, not about what `descry-runtime` can do. The two came
|
|
86
|
+
* apart once already, when the cross-boundary lane landed and this file still
|
|
87
|
+
* claimed the capability was absent. Re-check it against the installed tree
|
|
88
|
+
* when the pins move, not against the source repository.
|
|
89
|
+
* Descry.
|
|
90
|
+
*
|
|
91
|
+
* ## Why no denial is ever emitted
|
|
92
|
+
*
|
|
93
|
+
* `applyRuntimeObservations` accepts `held: false`. Nothing here ever sends
|
|
94
|
+
* one, and `confirmObservedFrontendCaller`'s own header explains why: a run
|
|
95
|
+
* establishes that a call *happened*; it cannot establish that one did not,
|
|
96
|
+
* because a run exercises the paths it happens to take. Demoting a correct
|
|
97
|
+
* edge on the strength of a route this run did not visit would be worse than
|
|
98
|
+
* never running.
|
|
99
|
+
*
|
|
100
|
+
* ## Class and tier
|
|
101
|
+
*
|
|
102
|
+
* `action` — DEC-278's own test is *"can this call's effect change a later
|
|
103
|
+
* finding without the developer having said so"*, and this one spawns
|
|
104
|
+
* processes and writes R4 edges that cap every later reliability class
|
|
105
|
+
* differently. It is gated by the same two-call `confirmToken` handshake
|
|
106
|
+
* `questions` uses, and additionally by the environment profile's declared
|
|
107
|
+
* `safetyLevel` (DEC-270): booting a service is a **write** against the
|
|
108
|
+
* target, so a `readOnly` profile refuses. An all-attach run is genuinely
|
|
109
|
+
* read-only — `ServiceConfiguration.attach`'s own contract is that Descry
|
|
110
|
+
* never executes code in, or applies limits to, a process it did not spawn —
|
|
111
|
+
* so it is allowed under `readOnly`, and that distinction is stated in the
|
|
112
|
+
* disclosures rather than inferred silently.
|
|
113
|
+
*
|
|
114
|
+
* `evidence` — it reports what was witnessed and what was written. It draws no
|
|
115
|
+
* conclusion about the user's code; nothing here reads or writes a finding, a
|
|
116
|
+
* hypothesis or a category (RT-027).
|
|
117
|
+
*
|
|
118
|
+
* ## No new query tools
|
|
119
|
+
*
|
|
120
|
+
* None are needed and none are added. `impact`, `propagation` and the rest
|
|
121
|
+
* already read the `resolution` field, so an edge this tool promotes to R4
|
|
122
|
+
* becomes visible through every one of them on the next call, with no change
|
|
123
|
+
* to any of them.
|
|
124
|
+
*/
|
|
125
|
+
import { mkdir } from "node:fs/promises";
|
|
126
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
127
|
+
import { independentSignalTypes } from "@descryy/ir";
|
|
128
|
+
import { buildGraph, counts, createConfirmedIncidentSource, persistGraph, } from "@descryy/core";
|
|
129
|
+
import { evaluateAction, validateProfile } from "@descryy/runtime-environment-profile";
|
|
130
|
+
import { correlateExecution } from "@descryy/runtime-evidence-correlation";
|
|
131
|
+
import { EvidenceStore } from "@descryy/runtime-evidence-store";
|
|
132
|
+
import { confirmObservedFrontendCaller } from "@descryy/runtime-graph-correlator";
|
|
133
|
+
import { runInstrumentedExecution } from "@descryy/runtime-orchestrator";
|
|
134
|
+
import { answer, optionalInteger, optionalString, ToolInputError, } from "./kit.js";
|
|
135
|
+
import { loadRuntimeAdapter, RuntimeAdapterLoadError } from "../runtime-registry.js";
|
|
136
|
+
import { writeConfirmedIncident } from "../session.js";
|
|
137
|
+
import { runtimeObservedIncident } from "./runtime-incident.js";
|
|
138
|
+
/** Where evidence lands when the call does not say. Beside the graph, not inside it. */
|
|
139
|
+
export const DEFAULT_EVIDENCE_RELATIVE_PATH = join(".descry", "evidence.db");
|
|
140
|
+
/** How long collectors are drained after the services report ready, when unstated. */
|
|
141
|
+
const DEFAULT_OBSERVE_MS = 5_000;
|
|
142
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
143
|
+
const DEFAULT_READINESS_TIMEOUT_MS = 30_000;
|
|
144
|
+
const READINESS_KINDS = ["http", "tcp-port", "command"];
|
|
145
|
+
const SCHEMA = {
|
|
146
|
+
type: "object",
|
|
147
|
+
properties: {
|
|
148
|
+
profile: {
|
|
149
|
+
type: "object",
|
|
150
|
+
description: "The environment this run targets. Every field is declared by you and never inferred from " +
|
|
151
|
+
"any other (DEC-270): a profile named \"staging\" with safetyLevel \"readOnly\" is read-only, " +
|
|
152
|
+
"and a profile named \"local\" with safetyLevel \"readOnly\" is too.",
|
|
153
|
+
properties: {
|
|
154
|
+
name: { type: "string", description: "Free-form. Matched against no vocabulary anywhere." },
|
|
155
|
+
url: { type: "string", description: "The target's base URL. Must parse." },
|
|
156
|
+
safetyLevel: {
|
|
157
|
+
type: "string",
|
|
158
|
+
enum: ["readOnly", "write", "destructiveWithApproval"],
|
|
159
|
+
description: "Booting a service is a write against the target, so \"readOnly\" refuses a run that " +
|
|
160
|
+
"spawns anything. A run in which every service uses \"attach\" spawns nothing and is " +
|
|
161
|
+
"permitted under \"readOnly\".",
|
|
162
|
+
},
|
|
163
|
+
credentialRef: {
|
|
164
|
+
type: "string",
|
|
165
|
+
description: "An opaque key into a credential store — never the secret itself.",
|
|
166
|
+
},
|
|
167
|
+
mode: { type: "string", enum: ["localBooted", "localAttached", "remote", "production"] },
|
|
168
|
+
},
|
|
169
|
+
required: ["name", "url", "safetyLevel", "credentialRef", "mode"],
|
|
170
|
+
additionalProperties: false,
|
|
171
|
+
},
|
|
172
|
+
services: {
|
|
173
|
+
type: "object",
|
|
174
|
+
description: "One entry per service, keyed by the name evidence will be attributed to. At least one is " +
|
|
175
|
+
"required. Exactly one of \"command\" or \"attach\" per service.",
|
|
176
|
+
additionalProperties: {
|
|
177
|
+
type: "object",
|
|
178
|
+
properties: {
|
|
179
|
+
command: { type: "string", description: "How to start it. Omit when using \"attach\"." },
|
|
180
|
+
cwd: {
|
|
181
|
+
type: "string",
|
|
182
|
+
description: "The directory this service is started in. Relative paths resolve against the " +
|
|
183
|
+
"repository root. REQUIRED with \"command\". With \"attach\" it is required only " +
|
|
184
|
+
"for a \"command\" readiness check, which is an executable Descry runs in it; for " +
|
|
185
|
+
"an \"http\" or \"tcp-port\" check it is not needed at all, because nothing is " +
|
|
186
|
+
"started and Descry never executes code in a process it did not spawn.",
|
|
187
|
+
},
|
|
188
|
+
// The description below is the fix for the single most expensive
|
|
189
|
+
// ergonomic gap this tool has: three failed runs in one real
|
|
190
|
+
// investigation, all the same root cause wearing different clothes,
|
|
191
|
+
// and the explanation both times living in a source comment.
|
|
192
|
+
port: {
|
|
193
|
+
type: "integer",
|
|
194
|
+
description: "The port readiness checks against, and the two modes need opposite things from you. " +
|
|
195
|
+
"With \"attach\": REQUIRED whenever readiness is \"http\" or \"tcp-port\" — the target " +
|
|
196
|
+
"chose its port before Descry saw it, and nothing in a pid or a log path reveals which, " +
|
|
197
|
+
"so this is refused up front rather than guessed. With \"command\": omit to get an " +
|
|
198
|
+
"ephemeral port, which is passed to your command as PORT; set it only if your command " +
|
|
199
|
+
"hardcodes a port, and then it must be THAT port — a readiness check against a port " +
|
|
200
|
+
"your command did not bind fails while the service is perfectly healthy.",
|
|
201
|
+
},
|
|
202
|
+
dependsOn: {
|
|
203
|
+
type: "array",
|
|
204
|
+
items: { type: "string" },
|
|
205
|
+
description: "Service names that must be ready first. Declared, never inferred.",
|
|
206
|
+
},
|
|
207
|
+
env: { type: "object", additionalProperties: { type: "string" } },
|
|
208
|
+
attach: {
|
|
209
|
+
type: "object",
|
|
210
|
+
description: "Observe a process that is already running instead of spawning one. Descry never " +
|
|
211
|
+
"executes code in, signals, or applies resource limits to a process it did not spawn.",
|
|
212
|
+
properties: {
|
|
213
|
+
pid: { type: "integer" },
|
|
214
|
+
logFilePath: {
|
|
215
|
+
type: "string",
|
|
216
|
+
description: "A file the target already writes its stdout/stderr to.",
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
required: ["pid", "logFilePath"],
|
|
220
|
+
additionalProperties: false,
|
|
221
|
+
},
|
|
222
|
+
readiness: {
|
|
223
|
+
type: "object",
|
|
224
|
+
description: "Required per service — a run refuses rather than treat \"the process started\" as " +
|
|
225
|
+
"\"the service is up\". Only the three mechanisms expressible as JSON are offered here; " +
|
|
226
|
+
"\"log-pattern\" and \"custom-hook\" need a function and are not reachable through this " +
|
|
227
|
+
"tool, which is disclosed on every call rather than left to be discovered.",
|
|
228
|
+
properties: {
|
|
229
|
+
kind: { type: "string", enum: [...READINESS_KINDS] },
|
|
230
|
+
path: {
|
|
231
|
+
type: "string",
|
|
232
|
+
description: "kind \"http\": path appended to http://127.0.0.1:<resolved port>. Defaults to \"/\".",
|
|
233
|
+
},
|
|
234
|
+
expectedStatus: { type: "integer", description: "kind \"http\": defaults to any 2xx/3xx." },
|
|
235
|
+
host: { type: "string", description: "kind \"tcp-port\": defaults to 127.0.0.1." },
|
|
236
|
+
command: { type: "string", description: "kind \"command\": the executable to run." },
|
|
237
|
+
args: { type: "array", items: { type: "string" }, description: "kind \"command\"." },
|
|
238
|
+
timeoutMs: { type: "integer", description: `Defaults to ${DEFAULT_READINESS_TIMEOUT_MS}.` },
|
|
239
|
+
},
|
|
240
|
+
required: ["kind"],
|
|
241
|
+
additionalProperties: false,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
required: ["readiness"],
|
|
245
|
+
additionalProperties: false,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
adapter: {
|
|
249
|
+
type: "object",
|
|
250
|
+
description: "The runtime adapter to observe with, named as a module specifier and imported at run time. " +
|
|
251
|
+
"This server depends on none of descry-runtime's per-language runtime adapters by design, and " +
|
|
252
|
+
"names none of them anywhere — including here, which is why this description carries no " +
|
|
253
|
+
"example specifier. Install the one matching the service's runtime alongside this server and " +
|
|
254
|
+
"name its package here; descry-runtime publishes one runtime adapter package per supported " +
|
|
255
|
+
"runtime, and its README lists them.",
|
|
256
|
+
properties: {
|
|
257
|
+
module: { type: "string" },
|
|
258
|
+
export: {
|
|
259
|
+
type: "string",
|
|
260
|
+
description: "Defaults to the single export matching create*RuntimeAdapter. Two matches is an error, " +
|
|
261
|
+
"not a coin toss — name one here.",
|
|
262
|
+
},
|
|
263
|
+
options: { type: "object", description: "Passed to the factory. Adapter-specific and opaque here." },
|
|
264
|
+
},
|
|
265
|
+
required: ["module"],
|
|
266
|
+
additionalProperties: false,
|
|
267
|
+
},
|
|
268
|
+
scopeByService: {
|
|
269
|
+
type: "object",
|
|
270
|
+
description: "Service name → which repository its symbols resolve in. A service with no entry has its " +
|
|
271
|
+
"symbol evidence left alone and its name reported, never resolved against a repository " +
|
|
272
|
+
"nobody named. Defaults to this session's own repo for every declared service.",
|
|
273
|
+
additionalProperties: {
|
|
274
|
+
type: "object",
|
|
275
|
+
properties: {
|
|
276
|
+
repo: { type: "string" },
|
|
277
|
+
repoRoot: { type: "string", description: "Absolute on-disk root, so observed absolute paths translate exactly." },
|
|
278
|
+
cwd: { type: "string" },
|
|
279
|
+
},
|
|
280
|
+
required: ["repo"],
|
|
281
|
+
additionalProperties: false,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
observeForMs: {
|
|
285
|
+
type: "integer",
|
|
286
|
+
description: `How long to drain collector output after the services are up. Defaults to ${DEFAULT_OBSERVE_MS}. ` +
|
|
287
|
+
"There is no \"the application is done\" signal at this layer — a server runs until stopped — " +
|
|
288
|
+
"so you state the observation window rather than this tool guessing at one.",
|
|
289
|
+
},
|
|
290
|
+
timeoutMs: { type: "integer", description: `Whole-execution budget. Defaults to ${DEFAULT_TIMEOUT_MS}.` },
|
|
291
|
+
environmentTier: {
|
|
292
|
+
type: "string",
|
|
293
|
+
enum: ["tier-0-ci-attached", "tier-1-preview", "tier-2-container", "tier-2b-api-only", "tier-3-static-only"],
|
|
294
|
+
description: "Recorded on the execution. Defaults to \"tier-2-container\" and is deliberately not derived " +
|
|
295
|
+
"from \"profile.mode\" — DEC-270's rule is that a declared field is declared, not inferred " +
|
|
296
|
+
"from a neighbouring one.",
|
|
297
|
+
},
|
|
298
|
+
fidelityLevel: {
|
|
299
|
+
type: "integer",
|
|
300
|
+
enum: [1, 2, 3, 4],
|
|
301
|
+
description: "1 rule-aware stub · 2 real code + disposable DB · 3 real code + redacted recordings · " +
|
|
302
|
+
"4 real staging. Defaults to 2. Not derived from anything else, same reason as environmentTier.",
|
|
303
|
+
},
|
|
304
|
+
evidencePath: {
|
|
305
|
+
type: "string",
|
|
306
|
+
description: `Where the evidence database lives. Defaults to ${DEFAULT_EVIDENCE_RELATIVE_PATH} under the repository root.`,
|
|
307
|
+
},
|
|
308
|
+
confirmToken: {
|
|
309
|
+
type: "string",
|
|
310
|
+
description: "The token returned by an unconfirmed call. This tool performs nothing without it: the first " +
|
|
311
|
+
"call describes what running would do and returns a token, and only a second call presenting " +
|
|
312
|
+
"that exact token runs anything — with the arguments frozen when the token was minted, never " +
|
|
313
|
+
"whatever the second call supplies.",
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
required: ["profile", "services", "adapter"],
|
|
317
|
+
additionalProperties: false,
|
|
318
|
+
};
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Argument reading. Hand-written, same reasoning as `kit.ts`'s own readers.
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
function asRecord(value, what) {
|
|
323
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
324
|
+
throw new ToolInputError(`"${what}" must be an object`);
|
|
325
|
+
}
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
function readProfile(args) {
|
|
329
|
+
const raw = asRecord(args["profile"], "profile");
|
|
330
|
+
const field = (key) => {
|
|
331
|
+
const value = raw[key];
|
|
332
|
+
if (typeof value !== "string")
|
|
333
|
+
throw new ToolInputError(`"profile.${key}" must be a string`);
|
|
334
|
+
return value;
|
|
335
|
+
};
|
|
336
|
+
const candidate = {
|
|
337
|
+
name: field("name"),
|
|
338
|
+
url: field("url"),
|
|
339
|
+
safetyLevel: field("safetyLevel"),
|
|
340
|
+
credentialRef: field("credentialRef"),
|
|
341
|
+
mode: field("mode"),
|
|
342
|
+
};
|
|
343
|
+
const errors = validateProfile(candidate);
|
|
344
|
+
if (errors.length > 0) {
|
|
345
|
+
// The profile package's own error codes, verbatim — this tool adds no
|
|
346
|
+
// interpretation to a validation it did not perform.
|
|
347
|
+
throw new ToolInputError(`"profile" is not valid: ${errors.join(", ")}`);
|
|
348
|
+
}
|
|
349
|
+
return candidate;
|
|
350
|
+
}
|
|
351
|
+
function readAdapterSpec(args) {
|
|
352
|
+
const raw = asRecord(args["adapter"], "adapter");
|
|
353
|
+
const module = raw["module"];
|
|
354
|
+
if (typeof module !== "string" || module === "") {
|
|
355
|
+
throw new ToolInputError('"adapter.module" is required and must be a non-empty string');
|
|
356
|
+
}
|
|
357
|
+
const exportName = raw["export"];
|
|
358
|
+
if (exportName !== undefined && typeof exportName !== "string") {
|
|
359
|
+
throw new ToolInputError('"adapter.export" must be a string');
|
|
360
|
+
}
|
|
361
|
+
const options = raw["options"];
|
|
362
|
+
if (options !== undefined && (typeof options !== "object" || options === null)) {
|
|
363
|
+
throw new ToolInputError('"adapter.options" must be an object');
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
module,
|
|
367
|
+
...(typeof exportName === "string" ? { export: exportName } : {}),
|
|
368
|
+
...(options === undefined ? {} : { options: options }),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function readServices(args, repoPath) {
|
|
372
|
+
const raw = asRecord(args["services"], "services");
|
|
373
|
+
const names = Object.keys(raw);
|
|
374
|
+
if (names.length === 0)
|
|
375
|
+
throw new ToolInputError('"services" must declare at least one service');
|
|
376
|
+
return names.map((name) => {
|
|
377
|
+
const entry = asRecord(raw[name], `services.${name}`);
|
|
378
|
+
const command = entry["command"];
|
|
379
|
+
const attachRaw = entry["attach"];
|
|
380
|
+
if ((command === undefined) === (attachRaw === undefined)) {
|
|
381
|
+
throw new ToolInputError(`services.${name} must declare exactly one of "command" or "attach" — ` +
|
|
382
|
+
(command === undefined ? "it declares neither" : "it declares both"));
|
|
383
|
+
}
|
|
384
|
+
if (command !== undefined && typeof command !== "string") {
|
|
385
|
+
throw new ToolInputError(`"services.${name}.command" must be a string`);
|
|
386
|
+
}
|
|
387
|
+
// Read once, here, because two separate rules below need it: whether a
|
|
388
|
+
// `cwd` is required at all, and whether an attached service must state its
|
|
389
|
+
// port.
|
|
390
|
+
const readinessRaw = entry["readiness"];
|
|
391
|
+
const readinessKind = typeof readinessRaw === "object" && readinessRaw !== null && !Array.isArray(readinessRaw)
|
|
392
|
+
? readinessRaw["kind"]
|
|
393
|
+
: undefined;
|
|
394
|
+
// `cwd` is required exactly where something will be executed in it, and
|
|
395
|
+
// nowhere else. Descry spawns nothing for an attached service and never
|
|
396
|
+
// executes code in a process it did not spawn, so for an attached service
|
|
397
|
+
// with an `http` or `tcp-port` check the value is inert — demanding it
|
|
398
|
+
// makes the caller invent a path that changes nothing. A `command` check
|
|
399
|
+
// *is* executed, in this directory, so it still needs one.
|
|
400
|
+
//
|
|
401
|
+
// The runtime contract's `ServiceConfiguration.cwd` is non-optional, so
|
|
402
|
+
// something must be supplied downstream either way; the repository root is
|
|
403
|
+
// the inert choice, and it is inert precisely because nothing runs there
|
|
404
|
+
// on this path.
|
|
405
|
+
// (`DEC-388`.)
|
|
406
|
+
const cwdRaw = entry["cwd"];
|
|
407
|
+
if (cwdRaw !== undefined && (typeof cwdRaw !== "string" || cwdRaw === "")) {
|
|
408
|
+
throw new ToolInputError(`"services.${name}.cwd" must be a non-empty string`);
|
|
409
|
+
}
|
|
410
|
+
if (cwdRaw === undefined && attachRaw === undefined) {
|
|
411
|
+
throw new ToolInputError(`"services.${name}.cwd" is required and must be a non-empty string: it is the directory ` +
|
|
412
|
+
`"${name}" is started in.`);
|
|
413
|
+
}
|
|
414
|
+
if (cwdRaw === undefined && attachRaw !== undefined && readinessKind === "command") {
|
|
415
|
+
throw new ToolInputError(`"services.${name}.cwd" is required when "${name}" uses "attach" with a "command" ` +
|
|
416
|
+
"readiness check: the check is an executable Descry runs, and it runs in this " +
|
|
417
|
+
"directory. Attaching needs no cwd otherwise — nothing is started.");
|
|
418
|
+
}
|
|
419
|
+
const cwd = cwdRaw ?? repoPath;
|
|
420
|
+
const port = entry["port"];
|
|
421
|
+
if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 0)) {
|
|
422
|
+
throw new ToolInputError(`"services.${name}.port" must be a non-negative integer`);
|
|
423
|
+
}
|
|
424
|
+
const dependsOn = entry["dependsOn"];
|
|
425
|
+
if (dependsOn !== undefined &&
|
|
426
|
+
(!Array.isArray(dependsOn) || dependsOn.some((d) => typeof d !== "string"))) {
|
|
427
|
+
throw new ToolInputError(`"services.${name}.dependsOn" must be an array of strings`);
|
|
428
|
+
}
|
|
429
|
+
const env = entry["env"];
|
|
430
|
+
if (env !== undefined) {
|
|
431
|
+
const record = asRecord(env, `services.${name}.env`);
|
|
432
|
+
for (const [key, value] of Object.entries(record)) {
|
|
433
|
+
if (typeof value !== "string") {
|
|
434
|
+
throw new ToolInputError(`"services.${name}.env.${key}" must be a string`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
let attach;
|
|
439
|
+
if (attachRaw !== undefined) {
|
|
440
|
+
const a = asRecord(attachRaw, `services.${name}.attach`);
|
|
441
|
+
const pid = a["pid"];
|
|
442
|
+
const logFilePath = a["logFilePath"];
|
|
443
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
|
|
444
|
+
throw new ToolInputError(`"services.${name}.attach.pid" must be a positive integer`);
|
|
445
|
+
}
|
|
446
|
+
if (typeof logFilePath !== "string" || logFilePath === "") {
|
|
447
|
+
throw new ToolInputError(`"services.${name}.attach.logFilePath" is required`);
|
|
448
|
+
}
|
|
449
|
+
attach = { pid, logFilePath };
|
|
450
|
+
// A spawned service gets an ephemeral port allocated for it; an attached
|
|
451
|
+
// one cannot, because the target chose its own port before Descry
|
|
452
|
+
// existed and nothing in a pid or a log path reveals which. The
|
|
453
|
+
// controller's refusal to guess is deliberate and right — but expressed
|
|
454
|
+
// as `port ?? 0`, it surfaces to a caller as a readiness check timing
|
|
455
|
+
// out against port 0 some seconds later, with the actual explanation
|
|
456
|
+
// living in a source comment they cannot see. Twice in one real
|
|
457
|
+
// investigation that cost a full failed run to rediscover. So it is
|
|
458
|
+
// refused here, by name, before anything starts.
|
|
459
|
+
//
|
|
460
|
+
// Only for the two checks that resolve a port. A "command" check runs an
|
|
461
|
+
// executable and never asks where the service listens, so demanding a
|
|
462
|
+
// port for it would be a second wrong answer in the other direction.
|
|
463
|
+
if ((readinessKind === "http" || readinessKind === "tcp-port") && port === undefined) {
|
|
464
|
+
throw new ToolInputError(`"services.${name}.port" is required when "${name}" uses "attach" with a ` +
|
|
465
|
+
`"${readinessKind}" readiness check: the check needs a port and an attached target's ` +
|
|
466
|
+
"port cannot be allocated or inferred — it is whatever the already-running process " +
|
|
467
|
+
"bound. State it, or use a \"command\" readiness check, which needs none.");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const configuration = {
|
|
471
|
+
...(typeof command === "string" ? { command } : {}),
|
|
472
|
+
cwd: isAbsolute(cwd) ? cwd : join(repoPath, cwd),
|
|
473
|
+
...(port === undefined ? {} : { port: port }),
|
|
474
|
+
...(dependsOn === undefined ? {} : { dependsOn: dependsOn }),
|
|
475
|
+
...(env === undefined ? {} : { env: env }),
|
|
476
|
+
...(attach === undefined ? {} : { attach }),
|
|
477
|
+
};
|
|
478
|
+
return {
|
|
479
|
+
name,
|
|
480
|
+
configuration,
|
|
481
|
+
readiness: readReadiness(entry["readiness"], name, configuration.cwd),
|
|
482
|
+
attached: attach !== undefined,
|
|
483
|
+
};
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* The JSON→`ReadinessCheck` mapping, and the two mechanisms it cannot express.
|
|
488
|
+
*
|
|
489
|
+
* `log-pattern` needs a `read()` closing over the `ManagedProcess` the
|
|
490
|
+
* controller owns, and `custom-hook` is a function outright. Neither survives a
|
|
491
|
+
* JSON boundary, and inventing a string-shaped stand-in for either would offer
|
|
492
|
+
* a mechanism that silently is not the one named. They are absent from the
|
|
493
|
+
* schema's enum and stated in the disclosures instead — rule 7, honest
|
|
494
|
+
* degradation, applied to a capability rather than to a result.
|
|
495
|
+
*/
|
|
496
|
+
function readReadiness(raw, service, cwd) {
|
|
497
|
+
const entry = asRecord(raw, `services.${service}.readiness`);
|
|
498
|
+
const kind = entry["kind"];
|
|
499
|
+
if (typeof kind !== "string" || !READINESS_KINDS.includes(kind)) {
|
|
500
|
+
throw new ToolInputError(`"services.${service}.readiness.kind" must be one of: ${READINESS_KINDS.join(", ")}`);
|
|
501
|
+
}
|
|
502
|
+
const timeoutMs = entry["timeoutMs"];
|
|
503
|
+
if (timeoutMs !== undefined &&
|
|
504
|
+
(typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs) || timeoutMs < 1)) {
|
|
505
|
+
throw new ToolInputError(`"services.${service}.readiness.timeoutMs" must be a positive integer`);
|
|
506
|
+
}
|
|
507
|
+
// Every field is validated **here**, not inside `checks`. The controller does
|
|
508
|
+
// not call `checks()` until the service has already spawned, so a bad
|
|
509
|
+
// argument validated lazily would surface as a failed run with a live process
|
|
510
|
+
// to clean up rather than as a rejected call that started nothing — and
|
|
511
|
+
// `ToolInputError`'s whole contract is that it is something the caller can
|
|
512
|
+
// fix before anything happens.
|
|
513
|
+
const path = typeof entry["path"] === "string" ? entry["path"] : "/";
|
|
514
|
+
const expectedStatus = entry["expectedStatus"];
|
|
515
|
+
if (expectedStatus !== undefined && typeof expectedStatus !== "number") {
|
|
516
|
+
throw new ToolInputError(`"services.${service}.readiness.expectedStatus" must be a number`);
|
|
517
|
+
}
|
|
518
|
+
const host = typeof entry["host"] === "string" ? entry["host"] : "127.0.0.1";
|
|
519
|
+
const command = entry["command"];
|
|
520
|
+
const commandArgs = entry["args"];
|
|
521
|
+
if (kind === "command") {
|
|
522
|
+
if (typeof command !== "string" || command === "") {
|
|
523
|
+
throw new ToolInputError(`"services.${service}.readiness.command" is required for kind "command"`);
|
|
524
|
+
}
|
|
525
|
+
if (commandArgs !== undefined &&
|
|
526
|
+
(!Array.isArray(commandArgs) || commandArgs.some((a) => typeof a !== "string"))) {
|
|
527
|
+
throw new ToolInputError(`"services.${service}.readiness.args" must be an array of strings`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const checks = (info) => {
|
|
531
|
+
if (kind === "http") {
|
|
532
|
+
return [
|
|
533
|
+
{
|
|
534
|
+
kind: "http",
|
|
535
|
+
url: `http://127.0.0.1:${String(info.port)}${path.startsWith("/") ? path : `/${path}`}`,
|
|
536
|
+
...(typeof expectedStatus === "number" ? { expectedStatus } : {}),
|
|
537
|
+
},
|
|
538
|
+
];
|
|
539
|
+
}
|
|
540
|
+
if (kind === "tcp-port") {
|
|
541
|
+
return [{ kind: "tcp-port", host, port: info.port }];
|
|
542
|
+
}
|
|
543
|
+
return [
|
|
544
|
+
{
|
|
545
|
+
kind: "command",
|
|
546
|
+
command: command,
|
|
547
|
+
...(commandArgs === undefined ? {} : { args: commandArgs }),
|
|
548
|
+
cwd,
|
|
549
|
+
},
|
|
550
|
+
];
|
|
551
|
+
};
|
|
552
|
+
return {
|
|
553
|
+
checks,
|
|
554
|
+
timeoutMs: typeof timeoutMs === "number" ? timeoutMs : DEFAULT_READINESS_TIMEOUT_MS,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function readScopes(args, declared, fallback) {
|
|
558
|
+
const scopes = {};
|
|
559
|
+
for (const service of declared)
|
|
560
|
+
scopes[service.name] = fallback;
|
|
561
|
+
const raw = args["scopeByService"];
|
|
562
|
+
if (raw === undefined)
|
|
563
|
+
return scopes;
|
|
564
|
+
for (const [name, value] of Object.entries(asRecord(raw, "scopeByService"))) {
|
|
565
|
+
const entry = asRecord(value, `scopeByService.${name}`);
|
|
566
|
+
const repo = entry["repo"];
|
|
567
|
+
if (typeof repo !== "string" || repo === "") {
|
|
568
|
+
throw new ToolInputError(`"scopeByService.${name}.repo" is required and must be a non-empty string`);
|
|
569
|
+
}
|
|
570
|
+
const repoRoot = entry["repoRoot"];
|
|
571
|
+
const cwd = entry["cwd"];
|
|
572
|
+
if (repoRoot !== undefined && typeof repoRoot !== "string") {
|
|
573
|
+
throw new ToolInputError(`"scopeByService.${name}.repoRoot" must be a string`);
|
|
574
|
+
}
|
|
575
|
+
if (cwd !== undefined && typeof cwd !== "string") {
|
|
576
|
+
throw new ToolInputError(`"scopeByService.${name}.cwd" must be a string`);
|
|
577
|
+
}
|
|
578
|
+
scopes[name] = {
|
|
579
|
+
repo,
|
|
580
|
+
...(repoRoot === undefined ? {} : { repoRoot }),
|
|
581
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
return scopes;
|
|
585
|
+
}
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
// The run
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
const EMPTY_WRITE = {
|
|
590
|
+
promoted: [],
|
|
591
|
+
created: [],
|
|
592
|
+
confirmed: [],
|
|
593
|
+
refused: [],
|
|
594
|
+
contradictions: [],
|
|
595
|
+
staleR4: [],
|
|
596
|
+
};
|
|
597
|
+
/**
|
|
598
|
+
* The disclosure every call carries, whatever it did.
|
|
599
|
+
*
|
|
600
|
+
* Stated unconditionally rather than only when it bites: a caller who does not
|
|
601
|
+
* know that `log-pattern` readiness is unreachable here will write a
|
|
602
|
+
* `tcp-port` check that passes the instant the socket binds and read the
|
|
603
|
+
* resulting empty evidence as "the service produced nothing".
|
|
604
|
+
*/
|
|
605
|
+
const STANDING_NOTES = [
|
|
606
|
+
"Readiness here offers only the three mechanisms JSON can state — http, tcp-port and command. " +
|
|
607
|
+
"log-pattern and custom-hook need a function and are unreachable through this tool; a run that " +
|
|
608
|
+
"needs one of those is not degraded here, it is unsupported here.",
|
|
609
|
+
"Edges are written only where an observation named both endpoints itself: a captured call-site " +
|
|
610
|
+
"stack resolving to a function, against an endpoint the same observation named. Every other " +
|
|
611
|
+
"correlated evidence item resolves a node and produces no edge, which is a gap in what this run " +
|
|
612
|
+
"could prove rather than evidence that no such edge exists.",
|
|
613
|
+
"That combination — HTTP evidence carrying a call-site stack — is produced here by the outbound-fetch " +
|
|
614
|
+
"instrumentation in @descryy/runtime-external-service-observation, which this server installs into a " +
|
|
615
|
+
"spawned service before any application code runs. So an observed outbound call CAN write this edge, " +
|
|
616
|
+
"and a run that makes none writes none: the backend collectors attach a stack to log and error lines " +
|
|
617
|
+
"(resolving a function) and none to inbound HTTP traffic (resolving an endpoint), so a service that " +
|
|
618
|
+
"never calls out resolves both kinds of node and still writes nothing. A zero here means this run " +
|
|
619
|
+
"observed no outbound call it could attribute, not that no such call exists in your code.",
|
|
620
|
+
"Two things this still cannot witness. An ATTACHED service is not launched by Descry, so the client " +
|
|
621
|
+
"instrumentation cannot be installed into it and its outbound calls carry no call-site stack. And " +
|
|
622
|
+
"browser-side requests need @descryy/runtime-browser, which is not in this server's closure — the " +
|
|
623
|
+
"browser_* tools refuse with a named remedy rather than appearing to work.",
|
|
624
|
+
"No denial is ever recorded. A run establishes that a call happened; it cannot establish that one " +
|
|
625
|
+
"did not, because it exercises only the paths it took. Nothing here demotes an edge.",
|
|
626
|
+
];
|
|
627
|
+
/**
|
|
628
|
+
* Said on every run that attaches, because it explains an absence that
|
|
629
|
+
* otherwise reads as a Descry defect — and did, in a real investigation, until
|
|
630
|
+
* it was measured.
|
|
631
|
+
*
|
|
632
|
+
* A request fired partway through an observation window did not appear in the
|
|
633
|
+
* evidence, and appeared in the log file afterwards. The obvious reading is
|
|
634
|
+
* that observation stopped early. It did not: reproduced end to end against a
|
|
635
|
+
* real attached service, a request fired at t≈5s of a 12s window landed in the
|
|
636
|
+
* evidence store, timestamped correctly. What actually happens is one layer
|
|
637
|
+
* out — a process whose stdout is redirected to a file is block-buffered, not
|
|
638
|
+
* line-buffered, because the descriptor is not a terminal. Measured directly:
|
|
639
|
+
* three lines written over 0.6s were still entirely absent from the file three
|
|
640
|
+
* seconds later, and arrived only when the process flushed.
|
|
641
|
+
*
|
|
642
|
+
* Nothing in Descry can see those bytes; they are in the target's own
|
|
643
|
+
* userspace buffer. So this is not a gap to close, it is a boundary to state —
|
|
644
|
+
* and stating it is what separates "we did not see it" from "it was not
|
|
645
|
+
* there", which is the whole difference this tool exists to preserve.
|
|
646
|
+
*
|
|
647
|
+
* Written for any redirected process, naming no language or framework: the
|
|
648
|
+
* behaviour is the C standard library's, and a note scoped to one ecosystem
|
|
649
|
+
* would invite one more such note per ecosystem.
|
|
650
|
+
*/
|
|
651
|
+
const ATTACH_BUFFERING_NOTE = "Attaching reads a file the target writes; it can only see what the target has already flushed " +
|
|
652
|
+
"there. A process whose output is redirected to a file is usually block-buffered rather than " +
|
|
653
|
+
"line-buffered — its own runtime holds whole lines in a userspace buffer, invisible from outside, " +
|
|
654
|
+
"until the buffer fills or the process flushes. Output produced during this window may therefore " +
|
|
655
|
+
"arrive in the file after the window closed and be absent here, which is a fact about the " +
|
|
656
|
+
"target's buffering and not evidence that it did nothing. Run the target with its output " +
|
|
657
|
+
"unbuffered or line-buffered if the timing matters.";
|
|
658
|
+
/**
|
|
659
|
+
* Every argument this tool takes, read and validated in one place.
|
|
660
|
+
*
|
|
661
|
+
* Extracted from `run` so that `describeAction` can call it too. That is the
|
|
662
|
+
* whole point of the extraction: an `action` tool's first call is the one a
|
|
663
|
+
* caller makes *before* it has a token, and therefore the only cheap place to
|
|
664
|
+
* learn its arguments are wrong. Minting a token, and a paragraph describing
|
|
665
|
+
* an action, for a declaration that cannot possibly run asks a developer to
|
|
666
|
+
* confirm something that was never going to happen — and delivers the real
|
|
667
|
+
* refusal on the second call, after the confirmation.
|
|
668
|
+
*
|
|
669
|
+
* Nothing here touches the filesystem, spawns anything or reads the graph: it
|
|
670
|
+
* is argument reading and nothing else, which is what makes it safe to run at
|
|
671
|
+
* mint time, outside the call budget.
|
|
672
|
+
*
|
|
673
|
+
* Ruled in `documents/decisions-inbox/DEC-387.md`.
|
|
674
|
+
*
|
|
675
|
+
* Exported for its own test: several of the argument rules are conditional and
|
|
676
|
+
* cheaper to assert directly than through a full run.
|
|
677
|
+
*/
|
|
678
|
+
export function readArguments(args, repoPath) {
|
|
679
|
+
const profile = readProfile(args);
|
|
680
|
+
const adapterSpec = readAdapterSpec(args);
|
|
681
|
+
const declared = readServices(args, repoPath);
|
|
682
|
+
const observeForMs = optionalInteger(args, "observeForMs", 1) ?? DEFAULT_OBSERVE_MS;
|
|
683
|
+
const timeoutMs = optionalInteger(args, "timeoutMs", 1) ?? DEFAULT_TIMEOUT_MS;
|
|
684
|
+
const fidelityRaw = optionalInteger(args, "fidelityLevel", 1) ?? 2;
|
|
685
|
+
if (fidelityRaw > 4)
|
|
686
|
+
throw new ToolInputError('"fidelityLevel" must be 1, 2, 3 or 4');
|
|
687
|
+
const environmentTier = optionalString(args, "environmentTier") ?? "tier-2-container";
|
|
688
|
+
const evidenceArg = optionalString(args, "evidencePath");
|
|
689
|
+
const evidencePath = evidenceArg === undefined
|
|
690
|
+
? join(repoPath, DEFAULT_EVIDENCE_RELATIVE_PATH)
|
|
691
|
+
: isAbsolute(evidenceArg)
|
|
692
|
+
? evidenceArg
|
|
693
|
+
: join(repoPath, evidenceArg);
|
|
694
|
+
return { profile, adapterSpec, declared, observeForMs, timeoutMs, fidelityRaw, environmentTier, evidencePath };
|
|
695
|
+
}
|
|
696
|
+
async function run(args, ctx) {
|
|
697
|
+
const session = ctx.session;
|
|
698
|
+
const { profile, adapterSpec, declared, observeForMs, timeoutMs, fidelityRaw, environmentTier, evidencePath } = readArguments(args, session.repoPath);
|
|
699
|
+
const notes = [...STANDING_NOTES];
|
|
700
|
+
// Added on the refusal paths too, deliberately. A caller who attaches and is
|
|
701
|
+
// then refused for some unrelated reason will fix that reason and attach
|
|
702
|
+
// again; telling them about the buffering only on the success path means
|
|
703
|
+
// telling them after the run whose result it would have explained.
|
|
704
|
+
if (declared.some((service) => service.attached))
|
|
705
|
+
notes.push(ATTACH_BUFFERING_NOTE);
|
|
706
|
+
const base = session.provider().baseStamp();
|
|
707
|
+
const refuse = (headline, data = {}) => answer({
|
|
708
|
+
headline,
|
|
709
|
+
state: "refused",
|
|
710
|
+
nameLevel: true,
|
|
711
|
+
// Nothing ran, so nothing was resolved. A refusal reporting the tier its
|
|
712
|
+
// successful path would have reached is the leaked-default this repo's
|
|
713
|
+
// own UAT already caught once elsewhere.
|
|
714
|
+
resolutionFloor: 0,
|
|
715
|
+
commitSha: base.commitSha,
|
|
716
|
+
graphBuiltAt: base.graphBuiltAt,
|
|
717
|
+
irSchemaVersion: base.irSchemaVersion,
|
|
718
|
+
commitSpread: base.commitSpread,
|
|
719
|
+
notes,
|
|
720
|
+
data: {
|
|
721
|
+
executionId: null,
|
|
722
|
+
executionState: null,
|
|
723
|
+
adapterLanguage: null,
|
|
724
|
+
evidencePath,
|
|
725
|
+
services: [],
|
|
726
|
+
evidenceByType: {},
|
|
727
|
+
correlation: null,
|
|
728
|
+
wrote: EMPTY_WRITE,
|
|
729
|
+
...data,
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
// --- the safety gate ------------------------------------------------------
|
|
733
|
+
// Spawning is a write against the target; attaching is not (Descry never
|
|
734
|
+
// executes code in, signals, or applies limits to a process it did not
|
|
735
|
+
// spawn). So the action's shape depends on what was declared, and the
|
|
736
|
+
// profile's declared level decides — never the profile's name.
|
|
737
|
+
const spawns = declared.some((service) => !service.attached);
|
|
738
|
+
const action = { write: spawns, destructive: false };
|
|
739
|
+
const decision = evaluateAction(profile, action);
|
|
740
|
+
if (decision !== "allow") {
|
|
741
|
+
return refuse(`Profile "${profile.name}" declares safetyLevel "${profile.safetyLevel}", which does not permit ` +
|
|
742
|
+
`${spawns ? "spawning a service" : "this run"}. Nothing was started and nothing was written. ` +
|
|
743
|
+
(spawns
|
|
744
|
+
? "A run in which every service uses \"attach\" spawns nothing and is permitted under readOnly."
|
|
745
|
+
: ""));
|
|
746
|
+
}
|
|
747
|
+
if (!spawns) {
|
|
748
|
+
notes.push("Every declared service is attached to rather than spawned, so this run started nothing and " +
|
|
749
|
+
"applied no resource, filesystem or network policy to any process — Descry does not constrain " +
|
|
750
|
+
"a process it did not spawn.");
|
|
751
|
+
}
|
|
752
|
+
// --- the graph must exist -------------------------------------------------
|
|
753
|
+
// Correlation resolves evidence against this graph. Against an empty one it
|
|
754
|
+
// resolves nothing, and reporting that as a clean run with no findings would
|
|
755
|
+
// be the exact "empty means broken" collapse the five states exist to stop.
|
|
756
|
+
const driver = session.store().driver;
|
|
757
|
+
const stored = counts(driver);
|
|
758
|
+
if (stored.nodes === 0) {
|
|
759
|
+
return refuse("This repository has no graph yet, so there is nothing for a run's evidence to be resolved " +
|
|
760
|
+
"against. Run analyze first — an observation that cannot name a node cannot become a fact.");
|
|
761
|
+
}
|
|
762
|
+
// --- the adapter ----------------------------------------------------------
|
|
763
|
+
ctx.progress(`Loading runtime adapter ${adapterSpec.module}`);
|
|
764
|
+
let adapter;
|
|
765
|
+
try {
|
|
766
|
+
adapter = await loadRuntimeAdapter(adapterSpec);
|
|
767
|
+
}
|
|
768
|
+
catch (error) {
|
|
769
|
+
if (error instanceof RuntimeAdapterLoadError) {
|
|
770
|
+
notes.push("This server depends on none of descry-runtime's language adapters by design, so the adapter " +
|
|
771
|
+
"must be installed alongside it and named in the call. Nothing was started.");
|
|
772
|
+
return refuse(error.message);
|
|
773
|
+
}
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
notes.push(`Observed with the runtime adapter for "${adapter.language}", loaded from ${adapterSpec.module}.`);
|
|
777
|
+
const root = await session.root();
|
|
778
|
+
const scopes = readScopes(args, declared, {
|
|
779
|
+
repo: root.repo,
|
|
780
|
+
repoRoot: root.absolutePath,
|
|
781
|
+
});
|
|
782
|
+
const services = {};
|
|
783
|
+
const readiness = {};
|
|
784
|
+
for (const service of declared) {
|
|
785
|
+
services[service.name] = service.configuration;
|
|
786
|
+
readiness[service.name] = service.readiness;
|
|
787
|
+
}
|
|
788
|
+
const configuration = {
|
|
789
|
+
environmentTier: environmentTier,
|
|
790
|
+
fidelityLevel: fidelityRaw,
|
|
791
|
+
timeoutMs,
|
|
792
|
+
services,
|
|
793
|
+
};
|
|
794
|
+
await mkdir(dirname(evidencePath), { recursive: true });
|
|
795
|
+
const evidenceStore = new EvidenceStore({ path: evidencePath });
|
|
796
|
+
try {
|
|
797
|
+
ctx.progress(`Running ${declared.length} service(s), observing for ${String(observeForMs)}ms`);
|
|
798
|
+
const execution = await runInstrumentedExecution({
|
|
799
|
+
execution: {
|
|
800
|
+
application: root.repo,
|
|
801
|
+
repository: root.repo,
|
|
802
|
+
commit: root.commitSha,
|
|
803
|
+
configuration,
|
|
804
|
+
},
|
|
805
|
+
runOptions: { readiness },
|
|
806
|
+
adapter,
|
|
807
|
+
store: evidenceStore,
|
|
808
|
+
observeForMs,
|
|
809
|
+
});
|
|
810
|
+
const observed = describeServices(execution.execution.processes, declared);
|
|
811
|
+
const evidenceByType = tally(execution.evidence);
|
|
812
|
+
if (execution.validationError !== null) {
|
|
813
|
+
// The controller refused before spawning anything. That is a fact about
|
|
814
|
+
// the declaration, not about the application — no evidence, no
|
|
815
|
+
// correlation, and emphatically not "the service is clean".
|
|
816
|
+
return refuse(`The execution refused to start: ${execution.validationError}. Nothing was spawned, no ` +
|
|
817
|
+
"evidence was collected, and no graph edge was written.", {
|
|
818
|
+
executionState: execution.execution.state,
|
|
819
|
+
adapterLanguage: adapter.language,
|
|
820
|
+
services: observed,
|
|
821
|
+
evidenceByType,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
ctx.progress(`Correlating ${String(execution.evidence.length)} evidence item(s) against the graph`);
|
|
825
|
+
const pass = correlateExecution({
|
|
826
|
+
store: evidenceStore,
|
|
827
|
+
driver,
|
|
828
|
+
executionId: execution.execution.executionId,
|
|
829
|
+
scopeByService: scopes,
|
|
830
|
+
});
|
|
831
|
+
const correlation = {
|
|
832
|
+
considered: pass.considered,
|
|
833
|
+
skipped: pass.skipped,
|
|
834
|
+
attributed: pass.attributed.length,
|
|
835
|
+
refused: pass.refusals.length,
|
|
836
|
+
unscopedServices: pass.unscopedServices,
|
|
837
|
+
harnessErrors: pass.harnessErrors.map((e) => `${e.detail} (${String(e.occurrences)}×)`),
|
|
838
|
+
};
|
|
839
|
+
if (pass.unscopedServices.length > 0) {
|
|
840
|
+
// Two different sentences, because they call for two different actions
|
|
841
|
+
// and the first one used to be printed for both. A named service with no
|
|
842
|
+
// scope is something the caller can fix by supplying one. The `(no
|
|
843
|
+
// service)` sentinel is not: `correlateExecution` looks a scope up by
|
|
844
|
+
// `evidence.service`, and no collector in this dependency closure stamps
|
|
845
|
+
// one — measured by the conformance run, which prints a real V8 stack
|
|
846
|
+
// resolving to a real graph node and watches it go unasked. Telling a
|
|
847
|
+
// caller to name a scope they have no key for would send them after a
|
|
848
|
+
// fix that does not exist, which is the honest-degradation rule failing
|
|
849
|
+
// in the one place it acts.
|
|
850
|
+
const named = pass.unscopedServices.filter((s) => s !== "(no service)");
|
|
851
|
+
const anonymous = pass.unscopedServices.length - named.length;
|
|
852
|
+
if (named.length > 0) {
|
|
853
|
+
notes.push(`Symbol evidence from ${named.join(", ")} was left unresolved: no scope named which ` +
|
|
854
|
+
"repository those symbols belong to, and resolving them against a repository nobody " +
|
|
855
|
+
"named would resolve the wrong one's identically-named file. Supply " +
|
|
856
|
+
'"scopeByService" for those services to have them resolved.');
|
|
857
|
+
}
|
|
858
|
+
if (anonymous > 0) {
|
|
859
|
+
notes.push("Some evidence carried a source location but no service name, so no scope could be " +
|
|
860
|
+
"looked up for it and its symbols were never resolved. This is not a missing argument: " +
|
|
861
|
+
"nothing in this server's runtime closure stamps a service name onto collector " +
|
|
862
|
+
"evidence, so there is no key a caller could supply a scope under. The items are " +
|
|
863
|
+
"counted as skipped rather than dropped, and what they would have resolved to is " +
|
|
864
|
+
"unknown rather than absent.");
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (pass.harnessErrors.length > 0) {
|
|
868
|
+
notes.push(`${String(pass.harnessErrors.length)} correlation failure(s) were the machinery breaking rather ` +
|
|
869
|
+
"than a resolver honestly declining — each is written into the evidence stream as a " +
|
|
870
|
+
"COLLECTOR_ERROR, and the counts below are correspondingly incomplete.");
|
|
871
|
+
}
|
|
872
|
+
// A service that died is a witnessed failure, and this run is the only
|
|
873
|
+
// thing that will ever have seen it. Recorded before the edge write so a
|
|
874
|
+
// failure in one does not silently cost the other.
|
|
875
|
+
// See `runtime-incident.ts` for why an EXCEPTION alone is not an incident.
|
|
876
|
+
const incident = runtimeObservedIncident({
|
|
877
|
+
repo: root.repo,
|
|
878
|
+
repoRoot: root.absolutePath,
|
|
879
|
+
runId: execution.execution.executionId,
|
|
880
|
+
services: observed,
|
|
881
|
+
exceptionLocations: execution.evidence
|
|
882
|
+
.filter((e) => e.eventType === "EXCEPTION")
|
|
883
|
+
.map((e) => ({ file: e.sourceLocation?.file ?? null })),
|
|
884
|
+
exceptionTexts: execution.evidence
|
|
885
|
+
.filter((e) => e.eventType === "EXCEPTION")
|
|
886
|
+
.map((e) => (typeof e.payload === "string" ? e.payload : JSON.stringify(e.payload)))
|
|
887
|
+
.map((text) => text.split("\n")[0] ?? "")
|
|
888
|
+
.filter((line) => line !== ""),
|
|
889
|
+
});
|
|
890
|
+
if (incident !== null) {
|
|
891
|
+
ctx.progress("Recording the observed failure as an incident");
|
|
892
|
+
await writeConfirmedIncident(session.repoPath, incident);
|
|
893
|
+
const projected = await createConfirmedIncidentSource({
|
|
894
|
+
repo: root.repo,
|
|
895
|
+
incidents: [...(session.config.confirmedIncidents ?? []), incident],
|
|
896
|
+
}).emit({ root });
|
|
897
|
+
persistGraph(driver, [projected], buildGraph([projected], {
|
|
898
|
+
nodeExists: (id) => session.provider().node(id) !== undefined,
|
|
899
|
+
}));
|
|
900
|
+
notes.push(`A service died during this run (${incident.summary}), so it was recorded as an incident ` +
|
|
901
|
+
`correlated to ${String(incident.files.length)} file(s) this repository owns, and written ` +
|
|
902
|
+
"durably to .descry/config.json — a run is gone once the process exits. The correlation " +
|
|
903
|
+
"is every file an exception stack named during the run, which is not a claim about the " +
|
|
904
|
+
"cause: nothing here knows which exception killed the process, and choosing the last one " +
|
|
905
|
+
"would be recency standing in for causality.");
|
|
906
|
+
}
|
|
907
|
+
ctx.progress("Writing observed edges into the graph");
|
|
908
|
+
const wrote = writeObservations({
|
|
909
|
+
driver,
|
|
910
|
+
pass,
|
|
911
|
+
evidenceStore,
|
|
912
|
+
repo: root.repo,
|
|
913
|
+
repoRoot: root.absolutePath,
|
|
914
|
+
runId: execution.execution.executionId,
|
|
915
|
+
commitSha: root.commitSha,
|
|
916
|
+
});
|
|
917
|
+
const written = wrote.promoted.length + wrote.created.length;
|
|
918
|
+
if (written === 0) {
|
|
919
|
+
notes.push("No edge was written. Either no observation carried a call-site stack that resolved to a " +
|
|
920
|
+
"function this graph holds, or every one it did carry was already at R4. Both are real " +
|
|
921
|
+
"outcomes of this run, and neither says the graph's existing edges are wrong.");
|
|
922
|
+
}
|
|
923
|
+
const headline = `Observed ${String(execution.evidence.length)} evidence item(s) across ${String(declared.length)} ` +
|
|
924
|
+
`service(s); ${String(pass.attributed.length)} resolved to graph nodes; ` +
|
|
925
|
+
`${String(wrote.promoted.length)} edge(s) promoted to R4 and ${String(wrote.created.length)} minted at R4.`;
|
|
926
|
+
return answer({
|
|
927
|
+
headline,
|
|
928
|
+
// Deliberately not `empty` when nothing was witnessed: `empty` is a claim
|
|
929
|
+
// about the population, and "this run took no path that exercised the
|
|
930
|
+
// code" is not "this code does nothing". The counts say what happened.
|
|
931
|
+
state: "ok",
|
|
932
|
+
nameLevel: true,
|
|
933
|
+
// R4 unconditionally, and honestly so — every fact in `wrote` was
|
|
934
|
+
// witnessed at runtime, which is the one resolution level that does not
|
|
935
|
+
// rest on inference (DEC-115). The correlated-but-unwritten items are
|
|
936
|
+
// not claimed here at all; they resolved a node and asserted nothing, so
|
|
937
|
+
// a run that wrote nothing reports R0 rather than borrowing the tier its
|
|
938
|
+
// successful path would have reached.
|
|
939
|
+
resolutionFloor: (written > 0 ? 4 : 0),
|
|
940
|
+
// G3's precondition and G4's `E`, declared because this run actually
|
|
941
|
+
// watched something. Only on a path where evidence really came back: a
|
|
942
|
+
// completed run that collected nothing witnessed nothing, and saying
|
|
943
|
+
// otherwise would assert a precondition on an empty array.
|
|
944
|
+
//
|
|
945
|
+
// Note this is deliberately *not* gated on `written > 0`. Whether an
|
|
946
|
+
// edge could be written is a fact about the graph path, and rule 3
|
|
947
|
+
// already caps the category through `resolutionFloor` just above — a
|
|
948
|
+
// run that saw three channels and wrote no edge is reported
|
|
949
|
+
// `unconfirmed` by the cap, not by pretending it saw nothing. Two
|
|
950
|
+
// separate facts, each stated once.
|
|
951
|
+
...(execution.evidence.length === 0
|
|
952
|
+
? {}
|
|
953
|
+
: { runtimeEvidence: { independentSignalTypes: witnessedSignalTypes(execution.evidence) } }),
|
|
954
|
+
commitSha: base.commitSha,
|
|
955
|
+
graphBuiltAt: base.graphBuiltAt,
|
|
956
|
+
irSchemaVersion: base.irSchemaVersion,
|
|
957
|
+
commitSpread: base.commitSpread,
|
|
958
|
+
notes,
|
|
959
|
+
data: {
|
|
960
|
+
executionId: execution.execution.executionId,
|
|
961
|
+
executionState: execution.execution.state,
|
|
962
|
+
adapterLanguage: adapter.language,
|
|
963
|
+
evidencePath,
|
|
964
|
+
services: observed,
|
|
965
|
+
evidenceByType,
|
|
966
|
+
correlation,
|
|
967
|
+
wrote,
|
|
968
|
+
},
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
finally {
|
|
972
|
+
evidenceStore.close();
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* One row per **declared** service, not one per spawned process — a service
|
|
977
|
+
* that never started must appear with `started: false` rather than vanish from
|
|
978
|
+
* the list, which is the difference between "it ran and did nothing" and "it
|
|
979
|
+
* never ran".
|
|
980
|
+
*/
|
|
981
|
+
function describeServices(processes, declared) {
|
|
982
|
+
const byService = new Map();
|
|
983
|
+
for (const handle of processes) {
|
|
984
|
+
if (handle.serviceName !== null)
|
|
985
|
+
byService.set(handle.serviceName, handle);
|
|
986
|
+
}
|
|
987
|
+
return declared.map((service) => {
|
|
988
|
+
const handle = byService.get(service.name);
|
|
989
|
+
return {
|
|
990
|
+
service: service.name,
|
|
991
|
+
started: handle !== undefined,
|
|
992
|
+
attached: service.attached,
|
|
993
|
+
port: handle?.port ?? null,
|
|
994
|
+
exitedAt: handle?.exitedAt ?? null,
|
|
995
|
+
exitCode: handle?.exitCode ?? null,
|
|
996
|
+
signal: handle?.signal ?? null,
|
|
997
|
+
};
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
function tally(evidence) {
|
|
1001
|
+
const byType = {};
|
|
1002
|
+
for (const item of evidence)
|
|
1003
|
+
byType[item.eventType] = (byType[item.eventType] ?? 0) + 1;
|
|
1004
|
+
return byType;
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* One evidence row onto one of `@descryy/ir`'s six `RUNTIME_SIGNAL_TYPES`, or
|
|
1008
|
+
* `null`.
|
|
1009
|
+
*
|
|
1010
|
+
* **Decided by `eventType`, with `source` consulted only where the event type
|
|
1011
|
+
* is genuinely ambiguous** — an exception can come off a browser console or a
|
|
1012
|
+
* backend process, and nothing but the collector says which. The obvious
|
|
1013
|
+
* alternative, reading `source` alone, is wrong and was measured to be wrong
|
|
1014
|
+
* rather than reasoned about: every row this repository's own conformance run
|
|
1015
|
+
* produces carries `source: "backend-process"`, and `EVIDENCE_SOURCES` also
|
|
1016
|
+
* has a `backend-log` value that nothing in the shipped dependency closure
|
|
1017
|
+
* emits. A source-driven table would have counted zero channels on every real
|
|
1018
|
+
* run while passing a hand-built test — the exact shape of failure that gets
|
|
1019
|
+
* caught by running the thing.
|
|
1020
|
+
*
|
|
1021
|
+
* **Everything not listed returns `null` and is counted as nothing.** That is
|
|
1022
|
+
* `RuntimeEvidence`'s own rule, not caution added here: `TEST_*` is excluded
|
|
1023
|
+
* by design (its evidentiary weight is `M`'s, never double-counted as a
|
|
1024
|
+
* channel too), harness actions are Descry driving the application rather than
|
|
1025
|
+
* observing it, process lifecycle is a fact about the process rather than
|
|
1026
|
+
* about its behaviour, and a collector or version-mismatch error is a fact
|
|
1027
|
+
* about the run. A kind this table has not ruled on must fail closed, because
|
|
1028
|
+
* the alternative — mapping it to the nearest-looking channel — raises `E`,
|
|
1029
|
+
* and therefore the reported category, with nobody having decided that it
|
|
1030
|
+
* should.
|
|
1031
|
+
*/
|
|
1032
|
+
function signalOf(item) {
|
|
1033
|
+
switch (item.eventType) {
|
|
1034
|
+
case "CONSOLE_MESSAGE":
|
|
1035
|
+
return "browser-console";
|
|
1036
|
+
case "NETWORK_REQUEST":
|
|
1037
|
+
case "NETWORK_RESPONSE":
|
|
1038
|
+
case "HTTP_ERROR":
|
|
1039
|
+
case "WEBSOCKET_CLOSED":
|
|
1040
|
+
return "network";
|
|
1041
|
+
case "SCREENSHOT":
|
|
1042
|
+
case "VIDEO":
|
|
1043
|
+
return "browser-visual";
|
|
1044
|
+
case "BACKEND_LOG":
|
|
1045
|
+
return "backend-log";
|
|
1046
|
+
case "DATABASE_QUERY":
|
|
1047
|
+
return "database";
|
|
1048
|
+
case "EXTERNAL_REQUEST":
|
|
1049
|
+
return "external-service";
|
|
1050
|
+
// The ambiguous pair, and the only place `source` decides: a thrown error
|
|
1051
|
+
// reaches Descry through whichever collector saw it, and that collector is
|
|
1052
|
+
// the channel.
|
|
1053
|
+
case "EXCEPTION":
|
|
1054
|
+
case "STACK_TRACE":
|
|
1055
|
+
return item.source === "browser-console" ? "browser-console" : "backend-log";
|
|
1056
|
+
default:
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* `E` for this run — how many of the six channels it actually saw.
|
|
1062
|
+
*
|
|
1063
|
+
* The count itself is `@descryy/ir`'s `independentSignalTypes`, deliberately:
|
|
1064
|
+
* the clamp to six and the drop of `null` signals are that function's rules,
|
|
1065
|
+
* and a second implementation of them here is a second place for the
|
|
1066
|
+
* vocabulary to drift. This function's only job is the translation above.
|
|
1067
|
+
*
|
|
1068
|
+
* Exported for its own test — the mapping decides whether a witnessed answer
|
|
1069
|
+
* reads `strongly supported` or `unconfirmed`, which is too load-bearing to be
|
|
1070
|
+
* asserted only through a category two layers downstream.
|
|
1071
|
+
*/
|
|
1072
|
+
export function witnessedSignalTypes(evidence) {
|
|
1073
|
+
return independentSignalTypes(evidence.map((item) => ({ signal: signalOf(item), detail: item.eventType })));
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* The R4 write, and the one join this tool performs itself.
|
|
1077
|
+
*
|
|
1078
|
+
* For every evidence item the correlation pass resolved to an endpoint, if that
|
|
1079
|
+
* same item also carried a call-site stack, ask `confirmObservedFrontendCaller`
|
|
1080
|
+
* whether the stack names a function — and when it does, it writes. Both
|
|
1081
|
+
* endpoints of the resulting edge come from the one observation; nothing here
|
|
1082
|
+
* pairs two separate items together. See the module header for why that
|
|
1083
|
+
* restraint is the whole design rather than a limitation of it.
|
|
1084
|
+
*/
|
|
1085
|
+
function writeObservations(input) {
|
|
1086
|
+
const promoted = [];
|
|
1087
|
+
const created = [];
|
|
1088
|
+
const confirmed = [];
|
|
1089
|
+
const refused = [];
|
|
1090
|
+
const contradictions = [];
|
|
1091
|
+
const staleR4 = [];
|
|
1092
|
+
// One evidence item can be attributed twice (an endpoint and a second naming
|
|
1093
|
+
// the same line carries). Keyed on both so the same (evidence, endpoint) pair
|
|
1094
|
+
// is never confirmed twice within one run.
|
|
1095
|
+
const seen = new Set();
|
|
1096
|
+
for (const attribution of input.pass.attributed) {
|
|
1097
|
+
// `endpoint` only, and `log-text-endpoint` deliberately excluded. That
|
|
1098
|
+
// family fires when a backend log LINE mentions a route and carries its
|
|
1099
|
+
// own stack frame — which establishes that the function logged about the
|
|
1100
|
+
// endpoint, not that it called it. The commonest real shape is a handler
|
|
1101
|
+
// logging "GET /invoices -> 500", and that function SERVES the endpoint
|
|
1102
|
+
// rather than USING it, so an edge minted from it could point the wrong
|
|
1103
|
+
// way. Rule 2: a wrong edge corrupts diff scoping, impact scores and
|
|
1104
|
+
// root-cause traversal; a missing one is a disclosed gap. Omitted.
|
|
1105
|
+
if (attribution.family !== "endpoint")
|
|
1106
|
+
continue;
|
|
1107
|
+
const key = `${attribution.evidenceId}::${attribution.graphNodeId}`;
|
|
1108
|
+
if (seen.has(key))
|
|
1109
|
+
continue;
|
|
1110
|
+
seen.add(key);
|
|
1111
|
+
const evidence = input.evidenceStore.getById(attribution.evidenceId);
|
|
1112
|
+
if (evidence === null || evidence.stackTrace === null)
|
|
1113
|
+
continue;
|
|
1114
|
+
const outcome = confirmObservedFrontendCaller(input.driver, {
|
|
1115
|
+
endpointNodeId: attribution.graphNodeId,
|
|
1116
|
+
stackTrace: evidence.stackTrace,
|
|
1117
|
+
repo: input.repo,
|
|
1118
|
+
runId: input.runId,
|
|
1119
|
+
commitSha: input.commitSha,
|
|
1120
|
+
repoRoot: input.repoRoot,
|
|
1121
|
+
});
|
|
1122
|
+
promoted.push(...outcome.confirmation.promoted);
|
|
1123
|
+
created.push(...outcome.confirmation.created);
|
|
1124
|
+
confirmed.push(...outcome.confirmation.confirmed);
|
|
1125
|
+
refused.push(...outcome.confirmation.refused.map((r) => r.reason));
|
|
1126
|
+
contradictions.push(...outcome.confirmation.contradictions.map((c) => c.detail));
|
|
1127
|
+
staleR4.push(...outcome.confirmation.staleR4.map((s) => s.detail));
|
|
1128
|
+
}
|
|
1129
|
+
return { promoted, created, confirmed, refused, contradictions, staleR4 };
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* What confirming this call would do, in the caller's own terms.
|
|
1133
|
+
*
|
|
1134
|
+
* Always returns a sentence — unlike `questions`, whose unconfirmed shape is a
|
|
1135
|
+
* genuine pure read of the question queue, there is no argument to this tool
|
|
1136
|
+
* that makes it not run anything. Every valid call spawns or attaches, and
|
|
1137
|
+
* every one of them can write.
|
|
1138
|
+
*/
|
|
1139
|
+
/**
|
|
1140
|
+
* §7's `willDo`, and — since `readArguments` is the first thing it does — the
|
|
1141
|
+
* point where an invalid call is refused.
|
|
1142
|
+
*
|
|
1143
|
+
* Returns a string on every valid call rather than ever returning `undefined`:
|
|
1144
|
+
* `undefined` means "this particular call has nothing to confirm", and there
|
|
1145
|
+
* is no such call here. Every accepted declaration boots or attaches to
|
|
1146
|
+
* something and may write durable R4 facts.
|
|
1147
|
+
*
|
|
1148
|
+
* Reads the *parsed* services rather than the raw object, so the sentence a
|
|
1149
|
+
* developer confirms is built from the same values the run will use — which
|
|
1150
|
+
* is also what makes "spawn" and "attach" here mean exactly what
|
|
1151
|
+
* `readServices` decided they mean, rather than a second, looser guess at the
|
|
1152
|
+
* same distinction.
|
|
1153
|
+
*/
|
|
1154
|
+
function describeAction(args, ctx) {
|
|
1155
|
+
const { declared } = readArguments(args, ctx.session.repoPath);
|
|
1156
|
+
const spawned = declared.filter((service) => !service.attached).map((service) => service.name);
|
|
1157
|
+
const attached = declared.filter((service) => service.attached).map((service) => service.name);
|
|
1158
|
+
const parts = [];
|
|
1159
|
+
if (spawned.length > 0)
|
|
1160
|
+
parts.push(`start ${String(spawned.length)} service(s) (${spawned.join(", ")})`);
|
|
1161
|
+
if (attached.length > 0) {
|
|
1162
|
+
parts.push(`attach to ${String(attached.length)} already-running service(s) (${attached.join(", ")})`);
|
|
1163
|
+
}
|
|
1164
|
+
return (`${parts.join(" and ")}, observe them, and write any edge the run witnesses into this ` +
|
|
1165
|
+
"repository's graph at R4 — a durable fact that raises every later finding resting on it to " +
|
|
1166
|
+
"reliability class A, and that survives re-indexing. Nothing is ever demoted or deleted.");
|
|
1167
|
+
}
|
|
1168
|
+
export const observeRuntimeTool = {
|
|
1169
|
+
name: "observe_runtime",
|
|
1170
|
+
class: "action",
|
|
1171
|
+
tier: "evidence",
|
|
1172
|
+
version: "1.0.0",
|
|
1173
|
+
title: "Run the application and record what was observed",
|
|
1174
|
+
description: "Boot or attach to the declared services, watch them with a runtime adapter, resolve what was " +
|
|
1175
|
+
"observed against this repository's graph, and record the edges the run actually witnessed at R4 " +
|
|
1176
|
+
"— the one resolution level static analysis cannot reach. Promoted and newly minted edges become " +
|
|
1177
|
+
"visible through impact, propagation and every other tool immediately, with no second call: they " +
|
|
1178
|
+
"already read the resolution field. Running takes a two-call confirmation — the first call " +
|
|
1179
|
+
"performs nothing and returns a token describing what it would do; call again with " +
|
|
1180
|
+
"\"confirmToken\" to actually run it. The environment profile's declared safetyLevel is checked " +
|
|
1181
|
+
"before anything starts: booting is a write, attaching is not. " +
|
|
1182
|
+
"BEFORE CALLING: this observes an application you can already run — it does not help you get to " +
|
|
1183
|
+
"a runnable state, and that boundary is real rather than apologetic. Its dependencies must be up, " +
|
|
1184
|
+
"its environment set, and its migrations applied, all by you. One trap worth stating because it " +
|
|
1185
|
+
"is invisible: overriding some environment variables does not isolate a run from the " +
|
|
1186
|
+
"application's own configuration file — anything you did not explicitly override is still read " +
|
|
1187
|
+
"from it, including values naming environments you did not intend to touch.",
|
|
1188
|
+
inputSchema: SCHEMA,
|
|
1189
|
+
run,
|
|
1190
|
+
describeAction,
|
|
1191
|
+
};
|
|
1192
|
+
//# sourceMappingURL=observe-runtime.js.map
|