@descryy/mcp 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/descry-mcp.js +24 -1
- package/dist/bin/descry-mcp.js.map +1 -1
- 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 +1 -1
- package/dist/index.d.ts.map +1 -1
- 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 +9 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +17 -2
- 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/server.d.ts.map +1 -1
- package/dist/server.js +5 -0
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts +74 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +177 -4
- package/dist/session.js.map +1 -1
- package/dist/tools/analyze.d.ts +11 -0
- package/dist/tools/analyze.d.ts.map +1 -1
- package/dist/tools/analyze.js +61 -1
- package/dist/tools/analyze.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 +24 -3
- 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.js +1 -1
- package/dist/tools/impact.js.map +1 -1
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/kit.d.ts +1 -0
- package/dist/tools/kit.d.ts.map +1 -1
- package/dist/tools/kit.js +1 -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 +198 -0
- package/dist/tools/observe-runtime.d.ts.map +1 -0
- package/dist/tools/observe-runtime.js +951 -0
- package/dist/tools/observe-runtime.js.map +1 -0
- package/dist/tools/pr-analysis.d.ts +60 -13
- package/dist/tools/pr-analysis.d.ts.map +1 -1
- package/dist/tools/pr-analysis.js +54 -11
- package/dist/tools/pr-analysis.js.map +1 -1
- 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 +66 -4
- 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.map +1 -1
- package/dist/tools/verification-status.js +1 -0
- 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 +15 -4
|
@@ -0,0 +1,951 @@
|
|
|
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 the arrow it carries is narrow, and the second half
|
|
56
|
+
* of that sentence is worth stating plainly because a reader would otherwise
|
|
57
|
+
* infer it from a zero. HTTP evidence carrying a call-site stack is emitted by
|
|
58
|
+
* exactly one collector in `descry-runtime`: the browser network collector,
|
|
59
|
+
* via its `fetch()` initiator capture. Checked across every collector in this
|
|
60
|
+
* server's dependency closure — `InboundProxy` emits `{method, path, status}`
|
|
61
|
+
* and no stack; `ExternalRequestCollector` sets `stackTrace: null` outright,
|
|
62
|
+
* because it parses a log line the application printed and a printed line
|
|
63
|
+
* carries no stack; `LogCollector` (the only thing the TypeScript runtime
|
|
64
|
+
* adapter builds) attaches a stack to log and error lines, which resolve a
|
|
65
|
+
* FUNCTION and name no endpoint.
|
|
66
|
+
*
|
|
67
|
+
* So a backend-only run resolves both kinds of node and writes no edge. The
|
|
68
|
+
* composition is right, the gate is real, and the arrow is currently reachable
|
|
69
|
+
* only from a browser-driven run — which `runInstrumentedExecution`'s own
|
|
70
|
+
* contract explicitly does not do ("does not drive a browser"), and which is
|
|
71
|
+
* therefore a different orchestration rather than a missing argument here.
|
|
72
|
+
* `STANDING_NOTES` says this on every call, whatever the counts come back as,
|
|
73
|
+
* because "no edge was written" and "nothing here could have written one" are
|
|
74
|
+
* different statements and only the second one is a fact about Descry.
|
|
75
|
+
*
|
|
76
|
+
* ## Why no denial is ever emitted
|
|
77
|
+
*
|
|
78
|
+
* `applyRuntimeObservations` accepts `held: false`. Nothing here ever sends
|
|
79
|
+
* one, and `confirmObservedFrontendCaller`'s own header explains why: a run
|
|
80
|
+
* establishes that a call *happened*; it cannot establish that one did not,
|
|
81
|
+
* because a run exercises the paths it happens to take. Demoting a correct
|
|
82
|
+
* edge on the strength of a route this run did not visit would be worse than
|
|
83
|
+
* never running.
|
|
84
|
+
*
|
|
85
|
+
* ## Class and tier
|
|
86
|
+
*
|
|
87
|
+
* `action` — DEC-278's own test is *"can this call's effect change a later
|
|
88
|
+
* finding without the developer having said so"*, and this one spawns
|
|
89
|
+
* processes and writes R4 edges that cap every later reliability class
|
|
90
|
+
* differently. It is gated by the same two-call `confirmToken` handshake
|
|
91
|
+
* `questions` uses, and additionally by the environment profile's declared
|
|
92
|
+
* `safetyLevel` (DEC-270): booting a service is a **write** against the
|
|
93
|
+
* target, so a `readOnly` profile refuses. An all-attach run is genuinely
|
|
94
|
+
* read-only — `ServiceConfiguration.attach`'s own contract is that Descry
|
|
95
|
+
* never executes code in, or applies limits to, a process it did not spawn —
|
|
96
|
+
* so it is allowed under `readOnly`, and that distinction is stated in the
|
|
97
|
+
* disclosures rather than inferred silently.
|
|
98
|
+
*
|
|
99
|
+
* `evidence` — it reports what was witnessed and what was written. It draws no
|
|
100
|
+
* conclusion about the user's code; nothing here reads or writes a finding, a
|
|
101
|
+
* hypothesis or a category (RT-027).
|
|
102
|
+
*
|
|
103
|
+
* ## No new query tools
|
|
104
|
+
*
|
|
105
|
+
* None are needed and none are added. `impact`, `propagation` and the rest
|
|
106
|
+
* already read the `resolution` field, so an edge this tool promotes to R4
|
|
107
|
+
* becomes visible through every one of them on the next call, with no change
|
|
108
|
+
* to any of them.
|
|
109
|
+
*/
|
|
110
|
+
import { mkdir } from "node:fs/promises";
|
|
111
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
112
|
+
import { buildGraph, counts, createConfirmedIncidentSource, persistGraph, } from "@descryy/core";
|
|
113
|
+
import { evaluateAction, validateProfile } from "@descryy/runtime-environment-profile";
|
|
114
|
+
import { correlateExecution } from "@descryy/runtime-evidence-correlation";
|
|
115
|
+
import { EvidenceStore } from "@descryy/runtime-evidence-store";
|
|
116
|
+
import { confirmObservedFrontendCaller } from "@descryy/runtime-graph-correlator";
|
|
117
|
+
import { runInstrumentedExecution } from "@descryy/runtime-orchestrator";
|
|
118
|
+
import { answer, optionalInteger, optionalString, ToolInputError, } from "./kit.js";
|
|
119
|
+
import { loadRuntimeAdapter, RuntimeAdapterLoadError } from "../runtime-registry.js";
|
|
120
|
+
import { writeConfirmedIncident } from "../session.js";
|
|
121
|
+
import { runtimeObservedIncident } from "./runtime-incident.js";
|
|
122
|
+
/** Where evidence lands when the call does not say. Beside the graph, not inside it. */
|
|
123
|
+
export const DEFAULT_EVIDENCE_RELATIVE_PATH = join(".descry", "evidence.db");
|
|
124
|
+
/** How long collectors are drained after the services report ready, when unstated. */
|
|
125
|
+
const DEFAULT_OBSERVE_MS = 5_000;
|
|
126
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
127
|
+
const DEFAULT_READINESS_TIMEOUT_MS = 30_000;
|
|
128
|
+
const READINESS_KINDS = ["http", "tcp-port", "command"];
|
|
129
|
+
const SCHEMA = {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: {
|
|
132
|
+
profile: {
|
|
133
|
+
type: "object",
|
|
134
|
+
description: "The environment this run targets. Every field is declared by you and never inferred from " +
|
|
135
|
+
"any other (DEC-270): a profile named \"staging\" with safetyLevel \"readOnly\" is read-only, " +
|
|
136
|
+
"and a profile named \"local\" with safetyLevel \"readOnly\" is too.",
|
|
137
|
+
properties: {
|
|
138
|
+
name: { type: "string", description: "Free-form. Matched against no vocabulary anywhere." },
|
|
139
|
+
url: { type: "string", description: "The target's base URL. Must parse." },
|
|
140
|
+
safetyLevel: {
|
|
141
|
+
type: "string",
|
|
142
|
+
enum: ["readOnly", "write", "destructiveWithApproval"],
|
|
143
|
+
description: "Booting a service is a write against the target, so \"readOnly\" refuses a run that " +
|
|
144
|
+
"spawns anything. A run in which every service uses \"attach\" spawns nothing and is " +
|
|
145
|
+
"permitted under \"readOnly\".",
|
|
146
|
+
},
|
|
147
|
+
credentialRef: {
|
|
148
|
+
type: "string",
|
|
149
|
+
description: "An opaque key into a credential store — never the secret itself.",
|
|
150
|
+
},
|
|
151
|
+
mode: { type: "string", enum: ["localBooted", "localAttached", "remote", "production"] },
|
|
152
|
+
},
|
|
153
|
+
required: ["name", "url", "safetyLevel", "credentialRef", "mode"],
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
},
|
|
156
|
+
services: {
|
|
157
|
+
type: "object",
|
|
158
|
+
description: "One entry per service, keyed by the name evidence will be attributed to. At least one is " +
|
|
159
|
+
"required. Exactly one of \"command\" or \"attach\" per service.",
|
|
160
|
+
additionalProperties: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: {
|
|
163
|
+
command: { type: "string", description: "How to start it. Omit when using \"attach\"." },
|
|
164
|
+
cwd: {
|
|
165
|
+
type: "string",
|
|
166
|
+
description: "Working directory. Relative paths resolve against the repository root.",
|
|
167
|
+
},
|
|
168
|
+
port: {
|
|
169
|
+
type: "integer",
|
|
170
|
+
description: "Explicit opt-in only. Omit to get an ephemeral port. An attached service should set " +
|
|
171
|
+
"this when you already know what the running target bound — nothing here can allocate " +
|
|
172
|
+
"one on its behalf.",
|
|
173
|
+
},
|
|
174
|
+
dependsOn: {
|
|
175
|
+
type: "array",
|
|
176
|
+
items: { type: "string" },
|
|
177
|
+
description: "Service names that must be ready first. Declared, never inferred.",
|
|
178
|
+
},
|
|
179
|
+
env: { type: "object", additionalProperties: { type: "string" } },
|
|
180
|
+
attach: {
|
|
181
|
+
type: "object",
|
|
182
|
+
description: "Observe a process that is already running instead of spawning one. Descry never " +
|
|
183
|
+
"executes code in, signals, or applies resource limits to a process it did not spawn.",
|
|
184
|
+
properties: {
|
|
185
|
+
pid: { type: "integer" },
|
|
186
|
+
logFilePath: {
|
|
187
|
+
type: "string",
|
|
188
|
+
description: "A file the target already writes its stdout/stderr to.",
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
required: ["pid", "logFilePath"],
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
},
|
|
194
|
+
readiness: {
|
|
195
|
+
type: "object",
|
|
196
|
+
description: "Required per service — a run refuses rather than treat \"the process started\" as " +
|
|
197
|
+
"\"the service is up\". Only the three mechanisms expressible as JSON are offered here; " +
|
|
198
|
+
"\"log-pattern\" and \"custom-hook\" need a function and are not reachable through this " +
|
|
199
|
+
"tool, which is disclosed on every call rather than left to be discovered.",
|
|
200
|
+
properties: {
|
|
201
|
+
kind: { type: "string", enum: [...READINESS_KINDS] },
|
|
202
|
+
path: {
|
|
203
|
+
type: "string",
|
|
204
|
+
description: "kind \"http\": path appended to http://127.0.0.1:<resolved port>. Defaults to \"/\".",
|
|
205
|
+
},
|
|
206
|
+
expectedStatus: { type: "integer", description: "kind \"http\": defaults to any 2xx/3xx." },
|
|
207
|
+
host: { type: "string", description: "kind \"tcp-port\": defaults to 127.0.0.1." },
|
|
208
|
+
command: { type: "string", description: "kind \"command\": the executable to run." },
|
|
209
|
+
args: { type: "array", items: { type: "string" }, description: "kind \"command\"." },
|
|
210
|
+
timeoutMs: { type: "integer", description: `Defaults to ${DEFAULT_READINESS_TIMEOUT_MS}.` },
|
|
211
|
+
},
|
|
212
|
+
required: ["kind"],
|
|
213
|
+
additionalProperties: false,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
required: ["cwd", "readiness"],
|
|
217
|
+
additionalProperties: false,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
adapter: {
|
|
221
|
+
type: "object",
|
|
222
|
+
description: "The runtime adapter to observe with, named as a module specifier and imported at run time. " +
|
|
223
|
+
"This server depends on none of descry-runtime's per-language runtime adapters by design, and " +
|
|
224
|
+
"names none of them anywhere — including here, which is why this description carries no " +
|
|
225
|
+
"example specifier. Install the one matching the service's runtime alongside this server and " +
|
|
226
|
+
"name its package here; descry-runtime publishes one runtime adapter package per supported " +
|
|
227
|
+
"runtime, and its README lists them.",
|
|
228
|
+
properties: {
|
|
229
|
+
module: { type: "string" },
|
|
230
|
+
export: {
|
|
231
|
+
type: "string",
|
|
232
|
+
description: "Defaults to the single export matching create*RuntimeAdapter. Two matches is an error, " +
|
|
233
|
+
"not a coin toss — name one here.",
|
|
234
|
+
},
|
|
235
|
+
options: { type: "object", description: "Passed to the factory. Adapter-specific and opaque here." },
|
|
236
|
+
},
|
|
237
|
+
required: ["module"],
|
|
238
|
+
additionalProperties: false,
|
|
239
|
+
},
|
|
240
|
+
scopeByService: {
|
|
241
|
+
type: "object",
|
|
242
|
+
description: "Service name → which repository its symbols resolve in. A service with no entry has its " +
|
|
243
|
+
"symbol evidence left alone and its name reported, never resolved against a repository " +
|
|
244
|
+
"nobody named. Defaults to this session's own repo for every declared service.",
|
|
245
|
+
additionalProperties: {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: {
|
|
248
|
+
repo: { type: "string" },
|
|
249
|
+
repoRoot: { type: "string", description: "Absolute on-disk root, so observed absolute paths translate exactly." },
|
|
250
|
+
cwd: { type: "string" },
|
|
251
|
+
},
|
|
252
|
+
required: ["repo"],
|
|
253
|
+
additionalProperties: false,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
observeForMs: {
|
|
257
|
+
type: "integer",
|
|
258
|
+
description: `How long to drain collector output after the services are up. Defaults to ${DEFAULT_OBSERVE_MS}. ` +
|
|
259
|
+
"There is no \"the application is done\" signal at this layer — a server runs until stopped — " +
|
|
260
|
+
"so you state the observation window rather than this tool guessing at one.",
|
|
261
|
+
},
|
|
262
|
+
timeoutMs: { type: "integer", description: `Whole-execution budget. Defaults to ${DEFAULT_TIMEOUT_MS}.` },
|
|
263
|
+
environmentTier: {
|
|
264
|
+
type: "string",
|
|
265
|
+
enum: ["tier-0-ci-attached", "tier-1-preview", "tier-2-container", "tier-2b-api-only", "tier-3-static-only"],
|
|
266
|
+
description: "Recorded on the execution. Defaults to \"tier-2-container\" and is deliberately not derived " +
|
|
267
|
+
"from \"profile.mode\" — DEC-270's rule is that a declared field is declared, not inferred " +
|
|
268
|
+
"from a neighbouring one.",
|
|
269
|
+
},
|
|
270
|
+
fidelityLevel: {
|
|
271
|
+
type: "integer",
|
|
272
|
+
enum: [1, 2, 3, 4],
|
|
273
|
+
description: "1 rule-aware stub · 2 real code + disposable DB · 3 real code + redacted recordings · " +
|
|
274
|
+
"4 real staging. Defaults to 2. Not derived from anything else, same reason as environmentTier.",
|
|
275
|
+
},
|
|
276
|
+
evidencePath: {
|
|
277
|
+
type: "string",
|
|
278
|
+
description: `Where the evidence database lives. Defaults to ${DEFAULT_EVIDENCE_RELATIVE_PATH} under the repository root.`,
|
|
279
|
+
},
|
|
280
|
+
confirmToken: {
|
|
281
|
+
type: "string",
|
|
282
|
+
description: "The token returned by an unconfirmed call. This tool performs nothing without it: the first " +
|
|
283
|
+
"call describes what running would do and returns a token, and only a second call presenting " +
|
|
284
|
+
"that exact token runs anything — with the arguments frozen when the token was minted, never " +
|
|
285
|
+
"whatever the second call supplies.",
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
required: ["profile", "services", "adapter"],
|
|
289
|
+
additionalProperties: false,
|
|
290
|
+
};
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Argument reading. Hand-written, same reasoning as `kit.ts`'s own readers.
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
function asRecord(value, what) {
|
|
295
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
296
|
+
throw new ToolInputError(`"${what}" must be an object`);
|
|
297
|
+
}
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
function readProfile(args) {
|
|
301
|
+
const raw = asRecord(args["profile"], "profile");
|
|
302
|
+
const field = (key) => {
|
|
303
|
+
const value = raw[key];
|
|
304
|
+
if (typeof value !== "string")
|
|
305
|
+
throw new ToolInputError(`"profile.${key}" must be a string`);
|
|
306
|
+
return value;
|
|
307
|
+
};
|
|
308
|
+
const candidate = {
|
|
309
|
+
name: field("name"),
|
|
310
|
+
url: field("url"),
|
|
311
|
+
safetyLevel: field("safetyLevel"),
|
|
312
|
+
credentialRef: field("credentialRef"),
|
|
313
|
+
mode: field("mode"),
|
|
314
|
+
};
|
|
315
|
+
const errors = validateProfile(candidate);
|
|
316
|
+
if (errors.length > 0) {
|
|
317
|
+
// The profile package's own error codes, verbatim — this tool adds no
|
|
318
|
+
// interpretation to a validation it did not perform.
|
|
319
|
+
throw new ToolInputError(`"profile" is not valid: ${errors.join(", ")}`);
|
|
320
|
+
}
|
|
321
|
+
return candidate;
|
|
322
|
+
}
|
|
323
|
+
function readAdapterSpec(args) {
|
|
324
|
+
const raw = asRecord(args["adapter"], "adapter");
|
|
325
|
+
const module = raw["module"];
|
|
326
|
+
if (typeof module !== "string" || module === "") {
|
|
327
|
+
throw new ToolInputError('"adapter.module" is required and must be a non-empty string');
|
|
328
|
+
}
|
|
329
|
+
const exportName = raw["export"];
|
|
330
|
+
if (exportName !== undefined && typeof exportName !== "string") {
|
|
331
|
+
throw new ToolInputError('"adapter.export" must be a string');
|
|
332
|
+
}
|
|
333
|
+
const options = raw["options"];
|
|
334
|
+
if (options !== undefined && (typeof options !== "object" || options === null)) {
|
|
335
|
+
throw new ToolInputError('"adapter.options" must be an object');
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
module,
|
|
339
|
+
...(typeof exportName === "string" ? { export: exportName } : {}),
|
|
340
|
+
...(options === undefined ? {} : { options: options }),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function readServices(args, repoPath) {
|
|
344
|
+
const raw = asRecord(args["services"], "services");
|
|
345
|
+
const names = Object.keys(raw);
|
|
346
|
+
if (names.length === 0)
|
|
347
|
+
throw new ToolInputError('"services" must declare at least one service');
|
|
348
|
+
return names.map((name) => {
|
|
349
|
+
const entry = asRecord(raw[name], `services.${name}`);
|
|
350
|
+
const command = entry["command"];
|
|
351
|
+
const attachRaw = entry["attach"];
|
|
352
|
+
if ((command === undefined) === (attachRaw === undefined)) {
|
|
353
|
+
throw new ToolInputError(`services.${name} must declare exactly one of "command" or "attach" — ` +
|
|
354
|
+
(command === undefined ? "it declares neither" : "it declares both"));
|
|
355
|
+
}
|
|
356
|
+
if (command !== undefined && typeof command !== "string") {
|
|
357
|
+
throw new ToolInputError(`"services.${name}.command" must be a string`);
|
|
358
|
+
}
|
|
359
|
+
const cwd = entry["cwd"];
|
|
360
|
+
if (typeof cwd !== "string" || cwd === "") {
|
|
361
|
+
throw new ToolInputError(`"services.${name}.cwd" is required and must be a non-empty string`);
|
|
362
|
+
}
|
|
363
|
+
const port = entry["port"];
|
|
364
|
+
if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 0)) {
|
|
365
|
+
throw new ToolInputError(`"services.${name}.port" must be a non-negative integer`);
|
|
366
|
+
}
|
|
367
|
+
const dependsOn = entry["dependsOn"];
|
|
368
|
+
if (dependsOn !== undefined &&
|
|
369
|
+
(!Array.isArray(dependsOn) || dependsOn.some((d) => typeof d !== "string"))) {
|
|
370
|
+
throw new ToolInputError(`"services.${name}.dependsOn" must be an array of strings`);
|
|
371
|
+
}
|
|
372
|
+
const env = entry["env"];
|
|
373
|
+
if (env !== undefined) {
|
|
374
|
+
const record = asRecord(env, `services.${name}.env`);
|
|
375
|
+
for (const [key, value] of Object.entries(record)) {
|
|
376
|
+
if (typeof value !== "string") {
|
|
377
|
+
throw new ToolInputError(`"services.${name}.env.${key}" must be a string`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
let attach;
|
|
382
|
+
if (attachRaw !== undefined) {
|
|
383
|
+
const a = asRecord(attachRaw, `services.${name}.attach`);
|
|
384
|
+
const pid = a["pid"];
|
|
385
|
+
const logFilePath = a["logFilePath"];
|
|
386
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
|
|
387
|
+
throw new ToolInputError(`"services.${name}.attach.pid" must be a positive integer`);
|
|
388
|
+
}
|
|
389
|
+
if (typeof logFilePath !== "string" || logFilePath === "") {
|
|
390
|
+
throw new ToolInputError(`"services.${name}.attach.logFilePath" is required`);
|
|
391
|
+
}
|
|
392
|
+
attach = { pid, logFilePath };
|
|
393
|
+
}
|
|
394
|
+
const configuration = {
|
|
395
|
+
...(typeof command === "string" ? { command } : {}),
|
|
396
|
+
cwd: isAbsolute(cwd) ? cwd : join(repoPath, cwd),
|
|
397
|
+
...(port === undefined ? {} : { port: port }),
|
|
398
|
+
...(dependsOn === undefined ? {} : { dependsOn: dependsOn }),
|
|
399
|
+
...(env === undefined ? {} : { env: env }),
|
|
400
|
+
...(attach === undefined ? {} : { attach }),
|
|
401
|
+
};
|
|
402
|
+
return {
|
|
403
|
+
name,
|
|
404
|
+
configuration,
|
|
405
|
+
readiness: readReadiness(entry["readiness"], name, configuration.cwd),
|
|
406
|
+
attached: attach !== undefined,
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The JSON→`ReadinessCheck` mapping, and the two mechanisms it cannot express.
|
|
412
|
+
*
|
|
413
|
+
* `log-pattern` needs a `read()` closing over the `ManagedProcess` the
|
|
414
|
+
* controller owns, and `custom-hook` is a function outright. Neither survives a
|
|
415
|
+
* JSON boundary, and inventing a string-shaped stand-in for either would offer
|
|
416
|
+
* a mechanism that silently is not the one named. They are absent from the
|
|
417
|
+
* schema's enum and stated in the disclosures instead — rule 7, honest
|
|
418
|
+
* degradation, applied to a capability rather than to a result.
|
|
419
|
+
*/
|
|
420
|
+
function readReadiness(raw, service, cwd) {
|
|
421
|
+
const entry = asRecord(raw, `services.${service}.readiness`);
|
|
422
|
+
const kind = entry["kind"];
|
|
423
|
+
if (typeof kind !== "string" || !READINESS_KINDS.includes(kind)) {
|
|
424
|
+
throw new ToolInputError(`"services.${service}.readiness.kind" must be one of: ${READINESS_KINDS.join(", ")}`);
|
|
425
|
+
}
|
|
426
|
+
const timeoutMs = entry["timeoutMs"];
|
|
427
|
+
if (timeoutMs !== undefined &&
|
|
428
|
+
(typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs) || timeoutMs < 1)) {
|
|
429
|
+
throw new ToolInputError(`"services.${service}.readiness.timeoutMs" must be a positive integer`);
|
|
430
|
+
}
|
|
431
|
+
// Every field is validated **here**, not inside `checks`. The controller does
|
|
432
|
+
// not call `checks()` until the service has already spawned, so a bad
|
|
433
|
+
// argument validated lazily would surface as a failed run with a live process
|
|
434
|
+
// to clean up rather than as a rejected call that started nothing — and
|
|
435
|
+
// `ToolInputError`'s whole contract is that it is something the caller can
|
|
436
|
+
// fix before anything happens.
|
|
437
|
+
const path = typeof entry["path"] === "string" ? entry["path"] : "/";
|
|
438
|
+
const expectedStatus = entry["expectedStatus"];
|
|
439
|
+
if (expectedStatus !== undefined && typeof expectedStatus !== "number") {
|
|
440
|
+
throw new ToolInputError(`"services.${service}.readiness.expectedStatus" must be a number`);
|
|
441
|
+
}
|
|
442
|
+
const host = typeof entry["host"] === "string" ? entry["host"] : "127.0.0.1";
|
|
443
|
+
const command = entry["command"];
|
|
444
|
+
const commandArgs = entry["args"];
|
|
445
|
+
if (kind === "command") {
|
|
446
|
+
if (typeof command !== "string" || command === "") {
|
|
447
|
+
throw new ToolInputError(`"services.${service}.readiness.command" is required for kind "command"`);
|
|
448
|
+
}
|
|
449
|
+
if (commandArgs !== undefined &&
|
|
450
|
+
(!Array.isArray(commandArgs) || commandArgs.some((a) => typeof a !== "string"))) {
|
|
451
|
+
throw new ToolInputError(`"services.${service}.readiness.args" must be an array of strings`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const checks = (info) => {
|
|
455
|
+
if (kind === "http") {
|
|
456
|
+
return [
|
|
457
|
+
{
|
|
458
|
+
kind: "http",
|
|
459
|
+
url: `http://127.0.0.1:${String(info.port)}${path.startsWith("/") ? path : `/${path}`}`,
|
|
460
|
+
...(typeof expectedStatus === "number" ? { expectedStatus } : {}),
|
|
461
|
+
},
|
|
462
|
+
];
|
|
463
|
+
}
|
|
464
|
+
if (kind === "tcp-port") {
|
|
465
|
+
return [{ kind: "tcp-port", host, port: info.port }];
|
|
466
|
+
}
|
|
467
|
+
return [
|
|
468
|
+
{
|
|
469
|
+
kind: "command",
|
|
470
|
+
command: command,
|
|
471
|
+
...(commandArgs === undefined ? {} : { args: commandArgs }),
|
|
472
|
+
cwd,
|
|
473
|
+
},
|
|
474
|
+
];
|
|
475
|
+
};
|
|
476
|
+
return {
|
|
477
|
+
checks,
|
|
478
|
+
timeoutMs: typeof timeoutMs === "number" ? timeoutMs : DEFAULT_READINESS_TIMEOUT_MS,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function readScopes(args, declared, fallback) {
|
|
482
|
+
const scopes = {};
|
|
483
|
+
for (const service of declared)
|
|
484
|
+
scopes[service.name] = fallback;
|
|
485
|
+
const raw = args["scopeByService"];
|
|
486
|
+
if (raw === undefined)
|
|
487
|
+
return scopes;
|
|
488
|
+
for (const [name, value] of Object.entries(asRecord(raw, "scopeByService"))) {
|
|
489
|
+
const entry = asRecord(value, `scopeByService.${name}`);
|
|
490
|
+
const repo = entry["repo"];
|
|
491
|
+
if (typeof repo !== "string" || repo === "") {
|
|
492
|
+
throw new ToolInputError(`"scopeByService.${name}.repo" is required and must be a non-empty string`);
|
|
493
|
+
}
|
|
494
|
+
const repoRoot = entry["repoRoot"];
|
|
495
|
+
const cwd = entry["cwd"];
|
|
496
|
+
if (repoRoot !== undefined && typeof repoRoot !== "string") {
|
|
497
|
+
throw new ToolInputError(`"scopeByService.${name}.repoRoot" must be a string`);
|
|
498
|
+
}
|
|
499
|
+
if (cwd !== undefined && typeof cwd !== "string") {
|
|
500
|
+
throw new ToolInputError(`"scopeByService.${name}.cwd" must be a string`);
|
|
501
|
+
}
|
|
502
|
+
scopes[name] = {
|
|
503
|
+
repo,
|
|
504
|
+
...(repoRoot === undefined ? {} : { repoRoot }),
|
|
505
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
return scopes;
|
|
509
|
+
}
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// The run
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
const EMPTY_WRITE = {
|
|
514
|
+
promoted: [],
|
|
515
|
+
created: [],
|
|
516
|
+
confirmed: [],
|
|
517
|
+
refused: [],
|
|
518
|
+
contradictions: [],
|
|
519
|
+
staleR4: [],
|
|
520
|
+
};
|
|
521
|
+
/**
|
|
522
|
+
* The disclosure every call carries, whatever it did.
|
|
523
|
+
*
|
|
524
|
+
* Stated unconditionally rather than only when it bites: a caller who does not
|
|
525
|
+
* know that `log-pattern` readiness is unreachable here will write a
|
|
526
|
+
* `tcp-port` check that passes the instant the socket binds and read the
|
|
527
|
+
* resulting empty evidence as "the service produced nothing".
|
|
528
|
+
*/
|
|
529
|
+
const STANDING_NOTES = [
|
|
530
|
+
"Readiness here offers only the three mechanisms JSON can state — http, tcp-port and command. " +
|
|
531
|
+
"log-pattern and custom-hook need a function and are unreachable through this tool; a run that " +
|
|
532
|
+
"needs one of those is not degraded here, it is unsupported here.",
|
|
533
|
+
"Edges are written only where an observation named both endpoints itself: a captured call-site " +
|
|
534
|
+
"stack resolving to a function, against an endpoint the same observation named. Every other " +
|
|
535
|
+
"correlated evidence item resolves a node and produces no edge, which is a gap in what this run " +
|
|
536
|
+
"could prove rather than evidence that no such edge exists.",
|
|
537
|
+
"That combination — HTTP evidence carrying a call-site stack — is produced today only by a browser " +
|
|
538
|
+
"network collector, which is not in this server's dependency closure. Backend collectors attach a " +
|
|
539
|
+
"stack to log and error lines (resolving a function) and attach none to HTTP traffic (resolving an " +
|
|
540
|
+
"endpoint), so a backend-only run resolves both kinds of node and writes no edge. That is a real " +
|
|
541
|
+
"limit of what can be witnessed here, not a failure of this run, and it is stated on every call " +
|
|
542
|
+
"rather than inferred from a zero.",
|
|
543
|
+
"No denial is ever recorded. A run establishes that a call happened; it cannot establish that one " +
|
|
544
|
+
"did not, because it exercises only the paths it took. Nothing here demotes an edge.",
|
|
545
|
+
];
|
|
546
|
+
async function run(args, ctx) {
|
|
547
|
+
const session = ctx.session;
|
|
548
|
+
const profile = readProfile(args);
|
|
549
|
+
const adapterSpec = readAdapterSpec(args);
|
|
550
|
+
const declared = readServices(args, session.repoPath);
|
|
551
|
+
const observeForMs = optionalInteger(args, "observeForMs", 1) ?? DEFAULT_OBSERVE_MS;
|
|
552
|
+
const timeoutMs = optionalInteger(args, "timeoutMs", 1) ?? DEFAULT_TIMEOUT_MS;
|
|
553
|
+
const fidelityRaw = optionalInteger(args, "fidelityLevel", 1) ?? 2;
|
|
554
|
+
if (fidelityRaw > 4)
|
|
555
|
+
throw new ToolInputError('"fidelityLevel" must be 1, 2, 3 or 4');
|
|
556
|
+
const environmentTier = optionalString(args, "environmentTier") ?? "tier-2-container";
|
|
557
|
+
const evidenceArg = optionalString(args, "evidencePath");
|
|
558
|
+
const evidencePath = evidenceArg === undefined
|
|
559
|
+
? join(session.repoPath, DEFAULT_EVIDENCE_RELATIVE_PATH)
|
|
560
|
+
: isAbsolute(evidenceArg)
|
|
561
|
+
? evidenceArg
|
|
562
|
+
: join(session.repoPath, evidenceArg);
|
|
563
|
+
const notes = [...STANDING_NOTES];
|
|
564
|
+
const base = session.provider().baseStamp();
|
|
565
|
+
const refuse = (headline, data = {}) => answer({
|
|
566
|
+
headline,
|
|
567
|
+
state: "refused",
|
|
568
|
+
nameLevel: true,
|
|
569
|
+
// Nothing ran, so nothing was resolved. A refusal reporting the tier its
|
|
570
|
+
// successful path would have reached is the leaked-default this repo's
|
|
571
|
+
// own UAT already caught once elsewhere.
|
|
572
|
+
resolutionFloor: 0,
|
|
573
|
+
commitSha: base.commitSha,
|
|
574
|
+
graphBuiltAt: base.graphBuiltAt,
|
|
575
|
+
irSchemaVersion: base.irSchemaVersion,
|
|
576
|
+
commitSpread: base.commitSpread,
|
|
577
|
+
notes,
|
|
578
|
+
data: {
|
|
579
|
+
executionId: null,
|
|
580
|
+
executionState: null,
|
|
581
|
+
adapterLanguage: null,
|
|
582
|
+
evidencePath,
|
|
583
|
+
services: [],
|
|
584
|
+
evidenceByType: {},
|
|
585
|
+
correlation: null,
|
|
586
|
+
wrote: EMPTY_WRITE,
|
|
587
|
+
...data,
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
// --- the safety gate ------------------------------------------------------
|
|
591
|
+
// Spawning is a write against the target; attaching is not (Descry never
|
|
592
|
+
// executes code in, signals, or applies limits to a process it did not
|
|
593
|
+
// spawn). So the action's shape depends on what was declared, and the
|
|
594
|
+
// profile's declared level decides — never the profile's name.
|
|
595
|
+
const spawns = declared.some((service) => !service.attached);
|
|
596
|
+
const action = { write: spawns, destructive: false };
|
|
597
|
+
const decision = evaluateAction(profile, action);
|
|
598
|
+
if (decision !== "allow") {
|
|
599
|
+
return refuse(`Profile "${profile.name}" declares safetyLevel "${profile.safetyLevel}", which does not permit ` +
|
|
600
|
+
`${spawns ? "spawning a service" : "this run"}. Nothing was started and nothing was written. ` +
|
|
601
|
+
(spawns
|
|
602
|
+
? "A run in which every service uses \"attach\" spawns nothing and is permitted under readOnly."
|
|
603
|
+
: ""));
|
|
604
|
+
}
|
|
605
|
+
if (!spawns) {
|
|
606
|
+
notes.push("Every declared service is attached to rather than spawned, so this run started nothing and " +
|
|
607
|
+
"applied no resource, filesystem or network policy to any process — Descry does not constrain " +
|
|
608
|
+
"a process it did not spawn.");
|
|
609
|
+
}
|
|
610
|
+
// --- the graph must exist -------------------------------------------------
|
|
611
|
+
// Correlation resolves evidence against this graph. Against an empty one it
|
|
612
|
+
// resolves nothing, and reporting that as a clean run with no findings would
|
|
613
|
+
// be the exact "empty means broken" collapse the five states exist to stop.
|
|
614
|
+
const driver = session.store().driver;
|
|
615
|
+
const stored = counts(driver);
|
|
616
|
+
if (stored.nodes === 0) {
|
|
617
|
+
return refuse("This repository has no graph yet, so there is nothing for a run's evidence to be resolved " +
|
|
618
|
+
"against. Run analyze first — an observation that cannot name a node cannot become a fact.");
|
|
619
|
+
}
|
|
620
|
+
// --- the adapter ----------------------------------------------------------
|
|
621
|
+
ctx.progress(`Loading runtime adapter ${adapterSpec.module}`);
|
|
622
|
+
let adapter;
|
|
623
|
+
try {
|
|
624
|
+
adapter = await loadRuntimeAdapter(adapterSpec);
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
if (error instanceof RuntimeAdapterLoadError) {
|
|
628
|
+
notes.push("This server depends on none of descry-runtime's language adapters by design, so the adapter " +
|
|
629
|
+
"must be installed alongside it and named in the call. Nothing was started.");
|
|
630
|
+
return refuse(error.message);
|
|
631
|
+
}
|
|
632
|
+
throw error;
|
|
633
|
+
}
|
|
634
|
+
notes.push(`Observed with the runtime adapter for "${adapter.language}", loaded from ${adapterSpec.module}.`);
|
|
635
|
+
const root = await session.root();
|
|
636
|
+
const scopes = readScopes(args, declared, {
|
|
637
|
+
repo: root.repo,
|
|
638
|
+
repoRoot: root.absolutePath,
|
|
639
|
+
});
|
|
640
|
+
const services = {};
|
|
641
|
+
const readiness = {};
|
|
642
|
+
for (const service of declared) {
|
|
643
|
+
services[service.name] = service.configuration;
|
|
644
|
+
readiness[service.name] = service.readiness;
|
|
645
|
+
}
|
|
646
|
+
const configuration = {
|
|
647
|
+
environmentTier: environmentTier,
|
|
648
|
+
fidelityLevel: fidelityRaw,
|
|
649
|
+
timeoutMs,
|
|
650
|
+
services,
|
|
651
|
+
};
|
|
652
|
+
await mkdir(dirname(evidencePath), { recursive: true });
|
|
653
|
+
const evidenceStore = new EvidenceStore({ path: evidencePath });
|
|
654
|
+
try {
|
|
655
|
+
ctx.progress(`Running ${declared.length} service(s), observing for ${String(observeForMs)}ms`);
|
|
656
|
+
const execution = await runInstrumentedExecution({
|
|
657
|
+
execution: {
|
|
658
|
+
application: root.repo,
|
|
659
|
+
repository: root.repo,
|
|
660
|
+
commit: root.commitSha,
|
|
661
|
+
configuration,
|
|
662
|
+
},
|
|
663
|
+
runOptions: { readiness },
|
|
664
|
+
adapter,
|
|
665
|
+
store: evidenceStore,
|
|
666
|
+
observeForMs,
|
|
667
|
+
});
|
|
668
|
+
const observed = describeServices(execution.execution.processes, declared);
|
|
669
|
+
const evidenceByType = tally(execution.evidence);
|
|
670
|
+
if (execution.validationError !== null) {
|
|
671
|
+
// The controller refused before spawning anything. That is a fact about
|
|
672
|
+
// the declaration, not about the application — no evidence, no
|
|
673
|
+
// correlation, and emphatically not "the service is clean".
|
|
674
|
+
return refuse(`The execution refused to start: ${execution.validationError}. Nothing was spawned, no ` +
|
|
675
|
+
"evidence was collected, and no graph edge was written.", {
|
|
676
|
+
executionState: execution.execution.state,
|
|
677
|
+
adapterLanguage: adapter.language,
|
|
678
|
+
services: observed,
|
|
679
|
+
evidenceByType,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
ctx.progress(`Correlating ${String(execution.evidence.length)} evidence item(s) against the graph`);
|
|
683
|
+
const pass = correlateExecution({
|
|
684
|
+
store: evidenceStore,
|
|
685
|
+
driver,
|
|
686
|
+
executionId: execution.execution.executionId,
|
|
687
|
+
scopeByService: scopes,
|
|
688
|
+
});
|
|
689
|
+
const correlation = {
|
|
690
|
+
considered: pass.considered,
|
|
691
|
+
skipped: pass.skipped,
|
|
692
|
+
attributed: pass.attributed.length,
|
|
693
|
+
refused: pass.refusals.length,
|
|
694
|
+
unscopedServices: pass.unscopedServices,
|
|
695
|
+
harnessErrors: pass.harnessErrors.map((e) => `${e.detail} (${String(e.occurrences)}×)`),
|
|
696
|
+
};
|
|
697
|
+
if (pass.unscopedServices.length > 0) {
|
|
698
|
+
// Two different sentences, because they call for two different actions
|
|
699
|
+
// and the first one used to be printed for both. A named service with no
|
|
700
|
+
// scope is something the caller can fix by supplying one. The `(no
|
|
701
|
+
// service)` sentinel is not: `correlateExecution` looks a scope up by
|
|
702
|
+
// `evidence.service`, and no collector in this dependency closure stamps
|
|
703
|
+
// one — measured by the conformance run, which prints a real V8 stack
|
|
704
|
+
// resolving to a real graph node and watches it go unasked. Telling a
|
|
705
|
+
// caller to name a scope they have no key for would send them after a
|
|
706
|
+
// fix that does not exist, which is the honest-degradation rule failing
|
|
707
|
+
// in the one place it acts.
|
|
708
|
+
const named = pass.unscopedServices.filter((s) => s !== "(no service)");
|
|
709
|
+
const anonymous = pass.unscopedServices.length - named.length;
|
|
710
|
+
if (named.length > 0) {
|
|
711
|
+
notes.push(`Symbol evidence from ${named.join(", ")} was left unresolved: no scope named which ` +
|
|
712
|
+
"repository those symbols belong to, and resolving them against a repository nobody " +
|
|
713
|
+
"named would resolve the wrong one's identically-named file. Supply " +
|
|
714
|
+
'"scopeByService" for those services to have them resolved.');
|
|
715
|
+
}
|
|
716
|
+
if (anonymous > 0) {
|
|
717
|
+
notes.push("Some evidence carried a source location but no service name, so no scope could be " +
|
|
718
|
+
"looked up for it and its symbols were never resolved. This is not a missing argument: " +
|
|
719
|
+
"nothing in this server's runtime closure stamps a service name onto collector " +
|
|
720
|
+
"evidence, so there is no key a caller could supply a scope under. The items are " +
|
|
721
|
+
"counted as skipped rather than dropped, and what they would have resolved to is " +
|
|
722
|
+
"unknown rather than absent.");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (pass.harnessErrors.length > 0) {
|
|
726
|
+
notes.push(`${String(pass.harnessErrors.length)} correlation failure(s) were the machinery breaking rather ` +
|
|
727
|
+
"than a resolver honestly declining — each is written into the evidence stream as a " +
|
|
728
|
+
"COLLECTOR_ERROR, and the counts below are correspondingly incomplete.");
|
|
729
|
+
}
|
|
730
|
+
// A service that died is a witnessed failure, and this run is the only
|
|
731
|
+
// thing that will ever have seen it. Recorded before the edge write so a
|
|
732
|
+
// failure in one does not silently cost the other.
|
|
733
|
+
// See `runtime-incident.ts` for why an EXCEPTION alone is not an incident.
|
|
734
|
+
const incident = runtimeObservedIncident({
|
|
735
|
+
repo: root.repo,
|
|
736
|
+
repoRoot: root.absolutePath,
|
|
737
|
+
runId: execution.execution.executionId,
|
|
738
|
+
services: observed,
|
|
739
|
+
exceptionLocations: execution.evidence
|
|
740
|
+
.filter((e) => e.eventType === "EXCEPTION")
|
|
741
|
+
.map((e) => ({ file: e.sourceLocation?.file ?? null })),
|
|
742
|
+
exceptionTexts: execution.evidence
|
|
743
|
+
.filter((e) => e.eventType === "EXCEPTION")
|
|
744
|
+
.map((e) => (typeof e.payload === "string" ? e.payload : JSON.stringify(e.payload)))
|
|
745
|
+
.map((text) => text.split("\n")[0] ?? "")
|
|
746
|
+
.filter((line) => line !== ""),
|
|
747
|
+
});
|
|
748
|
+
if (incident !== null) {
|
|
749
|
+
ctx.progress("Recording the observed failure as an incident");
|
|
750
|
+
await writeConfirmedIncident(session.repoPath, incident);
|
|
751
|
+
const projected = await createConfirmedIncidentSource({
|
|
752
|
+
repo: root.repo,
|
|
753
|
+
incidents: [...(session.config.confirmedIncidents ?? []), incident],
|
|
754
|
+
}).emit({ root });
|
|
755
|
+
persistGraph(driver, [projected], buildGraph([projected], {
|
|
756
|
+
nodeExists: (id) => session.provider().node(id) !== undefined,
|
|
757
|
+
}));
|
|
758
|
+
notes.push(`A service died during this run (${incident.summary}), so it was recorded as an incident ` +
|
|
759
|
+
`correlated to ${String(incident.files.length)} file(s) this repository owns, and written ` +
|
|
760
|
+
"durably to .descry/config.json — a run is gone once the process exits. The correlation " +
|
|
761
|
+
"is every file an exception stack named during the run, which is not a claim about the " +
|
|
762
|
+
"cause: nothing here knows which exception killed the process, and choosing the last one " +
|
|
763
|
+
"would be recency standing in for causality.");
|
|
764
|
+
}
|
|
765
|
+
ctx.progress("Writing observed edges into the graph");
|
|
766
|
+
const wrote = writeObservations({
|
|
767
|
+
driver,
|
|
768
|
+
pass,
|
|
769
|
+
evidenceStore,
|
|
770
|
+
repo: root.repo,
|
|
771
|
+
repoRoot: root.absolutePath,
|
|
772
|
+
runId: execution.execution.executionId,
|
|
773
|
+
commitSha: root.commitSha,
|
|
774
|
+
});
|
|
775
|
+
const written = wrote.promoted.length + wrote.created.length;
|
|
776
|
+
if (written === 0) {
|
|
777
|
+
notes.push("No edge was written. Either no observation carried a call-site stack that resolved to a " +
|
|
778
|
+
"function this graph holds, or every one it did carry was already at R4. Both are real " +
|
|
779
|
+
"outcomes of this run, and neither says the graph's existing edges are wrong.");
|
|
780
|
+
}
|
|
781
|
+
const headline = `Observed ${String(execution.evidence.length)} evidence item(s) across ${String(declared.length)} ` +
|
|
782
|
+
`service(s); ${String(pass.attributed.length)} resolved to graph nodes; ` +
|
|
783
|
+
`${String(wrote.promoted.length)} edge(s) promoted to R4 and ${String(wrote.created.length)} minted at R4.`;
|
|
784
|
+
return answer({
|
|
785
|
+
headline,
|
|
786
|
+
// Deliberately not `empty` when nothing was witnessed: `empty` is a claim
|
|
787
|
+
// about the population, and "this run took no path that exercised the
|
|
788
|
+
// code" is not "this code does nothing". The counts say what happened.
|
|
789
|
+
state: "ok",
|
|
790
|
+
nameLevel: true,
|
|
791
|
+
// R4 unconditionally, and honestly so — every fact in `wrote` was
|
|
792
|
+
// witnessed at runtime, which is the one resolution level that does not
|
|
793
|
+
// rest on inference (DEC-115). The correlated-but-unwritten items are
|
|
794
|
+
// not claimed here at all; they resolved a node and asserted nothing, so
|
|
795
|
+
// a run that wrote nothing reports R0 rather than borrowing the tier its
|
|
796
|
+
// successful path would have reached.
|
|
797
|
+
resolutionFloor: (written > 0 ? 4 : 0),
|
|
798
|
+
commitSha: base.commitSha,
|
|
799
|
+
graphBuiltAt: base.graphBuiltAt,
|
|
800
|
+
irSchemaVersion: base.irSchemaVersion,
|
|
801
|
+
commitSpread: base.commitSpread,
|
|
802
|
+
notes,
|
|
803
|
+
data: {
|
|
804
|
+
executionId: execution.execution.executionId,
|
|
805
|
+
executionState: execution.execution.state,
|
|
806
|
+
adapterLanguage: adapter.language,
|
|
807
|
+
evidencePath,
|
|
808
|
+
services: observed,
|
|
809
|
+
evidenceByType,
|
|
810
|
+
correlation,
|
|
811
|
+
wrote,
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
finally {
|
|
816
|
+
evidenceStore.close();
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* One row per **declared** service, not one per spawned process — a service
|
|
821
|
+
* that never started must appear with `started: false` rather than vanish from
|
|
822
|
+
* the list, which is the difference between "it ran and did nothing" and "it
|
|
823
|
+
* never ran".
|
|
824
|
+
*/
|
|
825
|
+
function describeServices(processes, declared) {
|
|
826
|
+
const byService = new Map();
|
|
827
|
+
for (const handle of processes) {
|
|
828
|
+
if (handle.serviceName !== null)
|
|
829
|
+
byService.set(handle.serviceName, handle);
|
|
830
|
+
}
|
|
831
|
+
return declared.map((service) => {
|
|
832
|
+
const handle = byService.get(service.name);
|
|
833
|
+
return {
|
|
834
|
+
service: service.name,
|
|
835
|
+
started: handle !== undefined,
|
|
836
|
+
attached: service.attached,
|
|
837
|
+
port: handle?.port ?? null,
|
|
838
|
+
exitedAt: handle?.exitedAt ?? null,
|
|
839
|
+
exitCode: handle?.exitCode ?? null,
|
|
840
|
+
signal: handle?.signal ?? null,
|
|
841
|
+
};
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
function tally(evidence) {
|
|
845
|
+
const byType = {};
|
|
846
|
+
for (const item of evidence)
|
|
847
|
+
byType[item.eventType] = (byType[item.eventType] ?? 0) + 1;
|
|
848
|
+
return byType;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* The R4 write, and the one join this tool performs itself.
|
|
852
|
+
*
|
|
853
|
+
* For every evidence item the correlation pass resolved to an endpoint, if that
|
|
854
|
+
* same item also carried a call-site stack, ask `confirmObservedFrontendCaller`
|
|
855
|
+
* whether the stack names a function — and when it does, it writes. Both
|
|
856
|
+
* endpoints of the resulting edge come from the one observation; nothing here
|
|
857
|
+
* pairs two separate items together. See the module header for why that
|
|
858
|
+
* restraint is the whole design rather than a limitation of it.
|
|
859
|
+
*/
|
|
860
|
+
function writeObservations(input) {
|
|
861
|
+
const promoted = [];
|
|
862
|
+
const created = [];
|
|
863
|
+
const confirmed = [];
|
|
864
|
+
const refused = [];
|
|
865
|
+
const contradictions = [];
|
|
866
|
+
const staleR4 = [];
|
|
867
|
+
// One evidence item can be attributed twice (an endpoint and a second naming
|
|
868
|
+
// the same line carries). Keyed on both so the same (evidence, endpoint) pair
|
|
869
|
+
// is never confirmed twice within one run.
|
|
870
|
+
const seen = new Set();
|
|
871
|
+
for (const attribution of input.pass.attributed) {
|
|
872
|
+
// `endpoint` only, and `log-text-endpoint` deliberately excluded. That
|
|
873
|
+
// family fires when a backend log LINE mentions a route and carries its
|
|
874
|
+
// own stack frame — which establishes that the function logged about the
|
|
875
|
+
// endpoint, not that it called it. The commonest real shape is a handler
|
|
876
|
+
// logging "GET /invoices -> 500", and that function SERVES the endpoint
|
|
877
|
+
// rather than USING it, so an edge minted from it could point the wrong
|
|
878
|
+
// way. Rule 2: a wrong edge corrupts diff scoping, impact scores and
|
|
879
|
+
// root-cause traversal; a missing one is a disclosed gap. Omitted.
|
|
880
|
+
if (attribution.family !== "endpoint")
|
|
881
|
+
continue;
|
|
882
|
+
const key = `${attribution.evidenceId}::${attribution.graphNodeId}`;
|
|
883
|
+
if (seen.has(key))
|
|
884
|
+
continue;
|
|
885
|
+
seen.add(key);
|
|
886
|
+
const evidence = input.evidenceStore.getById(attribution.evidenceId);
|
|
887
|
+
if (evidence === null || evidence.stackTrace === null)
|
|
888
|
+
continue;
|
|
889
|
+
const outcome = confirmObservedFrontendCaller(input.driver, {
|
|
890
|
+
endpointNodeId: attribution.graphNodeId,
|
|
891
|
+
stackTrace: evidence.stackTrace,
|
|
892
|
+
repo: input.repo,
|
|
893
|
+
runId: input.runId,
|
|
894
|
+
commitSha: input.commitSha,
|
|
895
|
+
repoRoot: input.repoRoot,
|
|
896
|
+
});
|
|
897
|
+
promoted.push(...outcome.confirmation.promoted);
|
|
898
|
+
created.push(...outcome.confirmation.created);
|
|
899
|
+
confirmed.push(...outcome.confirmation.confirmed);
|
|
900
|
+
refused.push(...outcome.confirmation.refused.map((r) => r.reason));
|
|
901
|
+
contradictions.push(...outcome.confirmation.contradictions.map((c) => c.detail));
|
|
902
|
+
staleR4.push(...outcome.confirmation.staleR4.map((s) => s.detail));
|
|
903
|
+
}
|
|
904
|
+
return { promoted, created, confirmed, refused, contradictions, staleR4 };
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* What confirming this call would do, in the caller's own terms.
|
|
908
|
+
*
|
|
909
|
+
* Always returns a sentence — unlike `questions`, whose unconfirmed shape is a
|
|
910
|
+
* genuine pure read of the question queue, there is no argument to this tool
|
|
911
|
+
* that makes it not run anything. Every valid call spawns or attaches, and
|
|
912
|
+
* every one of them can write.
|
|
913
|
+
*/
|
|
914
|
+
function describeAction(args) {
|
|
915
|
+
const services = args["services"];
|
|
916
|
+
if (typeof services !== "object" || services === null)
|
|
917
|
+
return undefined;
|
|
918
|
+
const names = Object.keys(services);
|
|
919
|
+
if (names.length === 0)
|
|
920
|
+
return undefined;
|
|
921
|
+
const entries = services;
|
|
922
|
+
const spawned = names.filter((n) => entries[n]?.attach === undefined);
|
|
923
|
+
const attached = names.filter((n) => entries[n]?.attach !== undefined);
|
|
924
|
+
const parts = [];
|
|
925
|
+
if (spawned.length > 0)
|
|
926
|
+
parts.push(`start ${spawned.length} service(s) (${spawned.join(", ")})`);
|
|
927
|
+
if (attached.length > 0)
|
|
928
|
+
parts.push(`attach to ${attached.length} already-running service(s) (${attached.join(", ")})`);
|
|
929
|
+
return (`${parts.join(" and ")}, observe them, and write any edge the run witnesses into this ` +
|
|
930
|
+
"repository's graph at R4 — a durable fact that raises every later finding resting on it to " +
|
|
931
|
+
"reliability class A, and that survives re-indexing. Nothing is ever demoted or deleted.");
|
|
932
|
+
}
|
|
933
|
+
export const observeRuntimeTool = {
|
|
934
|
+
name: "observe_runtime",
|
|
935
|
+
class: "action",
|
|
936
|
+
tier: "evidence",
|
|
937
|
+
version: "1.0.0",
|
|
938
|
+
title: "Run the application and record what was observed",
|
|
939
|
+
description: "Boot or attach to the declared services, watch them with a runtime adapter, resolve what was " +
|
|
940
|
+
"observed against this repository's graph, and record the edges the run actually witnessed at R4 " +
|
|
941
|
+
"— the one resolution level static analysis cannot reach. Promoted and newly minted edges become " +
|
|
942
|
+
"visible through impact, propagation and every other tool immediately, with no second call: they " +
|
|
943
|
+
"already read the resolution field. Running takes a two-call confirmation — the first call " +
|
|
944
|
+
"performs nothing and returns a token describing what it would do; call again with " +
|
|
945
|
+
"\"confirmToken\" to actually run it. The environment profile's declared safetyLevel is checked " +
|
|
946
|
+
"before anything starts: booting is a write, attaching is not.",
|
|
947
|
+
inputSchema: SCHEMA,
|
|
948
|
+
run,
|
|
949
|
+
describeAction,
|
|
950
|
+
};
|
|
951
|
+
//# sourceMappingURL=observe-runtime.js.map
|