omp-conductor 0.18.1 → 0.19.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/README.md +106 -41
- package/REFERENCE.md +866 -31
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +32 -1
- package/src/admission.ts +212 -26
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +27 -13
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +127 -2
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +52 -8
- package/src/commands/arm.ts +6 -2
- package/src/commands/context.ts +2 -0
- package/src/commands/intake.ts +4 -19
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +38 -6
- package/src/config.ts +103 -8
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1368 -529
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +431 -148
- package/src/escalate.ts +22 -11
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +587 -230
- package/src/host.ts +6 -455
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +40 -56
- package/src/orchestrator-tick.ts +564 -121
- package/src/pause.ts +233 -0
- package/src/session-host.ts +6 -41
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +343 -1160
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +252 -51
- package/src/setup.ts +87 -4
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +485 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +50 -2
- package/src/types.ts +759 -10
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +485 -11
- package/src/wake.ts +48 -0
- package/src/worker.ts +401 -14
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the installed `omp-telegram` current, and is its daemon running it? (#961)
|
|
3
|
+
*
|
|
4
|
+
* Conductor already proves its *own* three install surfaces carry one identity
|
|
5
|
+
* (#904/#915) and shows divergence without an on-demand run (#919). This is the
|
|
6
|
+
* same check for the one plugin a documented conductor contract depends on.
|
|
7
|
+
*
|
|
8
|
+
* The incident that motivated it: the package floor mandates answering an
|
|
9
|
+
* inbound message with a targetless `telegram_send`, so the reply stays in the
|
|
10
|
+
* topic it arrived in. On 2026-08-21 that call refused three times with
|
|
11
|
+
* `no active telegram chat — pass chat_id`, and each refusal cost an improvised
|
|
12
|
+
* recovery — once by passing route ids by hand, which is precisely the
|
|
13
|
+
* anti-pattern #882 names as its silent fake. The cause was that the installed
|
|
14
|
+
* 0.12.1 predates the target ladder: its send path had two rungs, and the
|
|
15
|
+
* published 0.12.2 has four plus per-rung diagnostics. The fix had been on npm
|
|
16
|
+
* for days. No conductor surface said so, so three sessions did transcript
|
|
17
|
+
* archaeology to reach a conclusion `npm view` answers in one call.
|
|
18
|
+
*
|
|
19
|
+
* Three properties are deliberate:
|
|
20
|
+
*
|
|
21
|
+
* - **Read-only, and never a remedy.** Installing stays operator-owned under the
|
|
22
|
+
* running-conductor boundary. The deliverable is that the operator is told.
|
|
23
|
+
* - **Offline-tolerant.** A registry that cannot be reached is `unknown`, never
|
|
24
|
+
* a finding. A fleet with no outbound npm access must not grow a permanent
|
|
25
|
+
* warning it can do nothing about.
|
|
26
|
+
* - **No baked-in baseline.** A minimum version compiled into conductor goes
|
|
27
|
+
* stale in exactly the way this module exists to catch, and re-creates #904's
|
|
28
|
+
* divergence with one more copy of the number. Every version compared here is
|
|
29
|
+
* read from a surface that exists at runtime.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
/** The plugin this checks. Named once. */
|
|
37
|
+
export const TELEGRAM_PACKAGE = "omp-telegram";
|
|
38
|
+
|
|
39
|
+
/** One surface's answer: a version, or why there isn't one. */
|
|
40
|
+
export type VersionRead =
|
|
41
|
+
| { kind: "version"; version: string }
|
|
42
|
+
| { kind: "absent" }
|
|
43
|
+
| { kind: "unreadable"; problem: string };
|
|
44
|
+
|
|
45
|
+
/** What the three surfaces said. */
|
|
46
|
+
export interface TelegramSurfaces {
|
|
47
|
+
/** The copy on disk, as `omp plugin list --json` reports it. */
|
|
48
|
+
installed: VersionRead;
|
|
49
|
+
/** What the running daemon says it is, from its own record. */
|
|
50
|
+
daemon: VersionRead;
|
|
51
|
+
/** The newest published version, or `unreadable` when the registry is not
|
|
52
|
+
* reachable — which is a non-finding, not a problem. */
|
|
53
|
+
published: VersionRead;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The verdict, in the vocabulary a surface renders.
|
|
58
|
+
*
|
|
59
|
+
* `unknown` is load-bearing: it is what an unreachable registry or an absent
|
|
60
|
+
* daemon produces, and it must never read as "current". A check that cannot run
|
|
61
|
+
* says so rather than certifying.
|
|
62
|
+
*/
|
|
63
|
+
export type TelegramFreshnessState =
|
|
64
|
+
| "current"
|
|
65
|
+
| "installed-behind"
|
|
66
|
+
| "daemon-stale"
|
|
67
|
+
| "not-installed"
|
|
68
|
+
| "unknown";
|
|
69
|
+
|
|
70
|
+
export interface TelegramFreshness {
|
|
71
|
+
state: TelegramFreshnessState;
|
|
72
|
+
surfaces: TelegramSurfaces;
|
|
73
|
+
/** One operator-facing sentence, or undefined when there is nothing to say.
|
|
74
|
+
* Never contains a token, chat id or topic id — only versions and paths. */
|
|
75
|
+
detail?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Numeric-dotted comparison, prerelease-agnostic: -1, 0 or 1.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately not a semver dependency. The only question asked here is
|
|
81
|
+
* "is the installed one older than the published one", and a prerelease
|
|
82
|
+
* suffix is compared as a tiebreak string rather than by semver's precedence
|
|
83
|
+
* rules — an installed prerelease of the newest version is not behind. */
|
|
84
|
+
export function compareVersions(a: string, b: string): number {
|
|
85
|
+
const parse = (v: string): { nums: number[]; rest: string } => {
|
|
86
|
+
const m = /^v?(\d+(?:\.\d+)*)(.*)$/.exec(v.trim());
|
|
87
|
+
if (m === null) return { nums: [], rest: v.trim() };
|
|
88
|
+
return { nums: m[1]!.split(".").map((n) => Number(n)), rest: m[2] ?? "" };
|
|
89
|
+
};
|
|
90
|
+
const pa = parse(a);
|
|
91
|
+
const pb = parse(b);
|
|
92
|
+
const width = Math.max(pa.nums.length, pb.nums.length);
|
|
93
|
+
for (let i = 0; i < width; i++) {
|
|
94
|
+
const x = pa.nums[i] ?? 0;
|
|
95
|
+
const y = pb.nums[i] ?? 0;
|
|
96
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
97
|
+
}
|
|
98
|
+
if (pa.rest === pb.rest) return 0;
|
|
99
|
+
// A release sorts above its own prereleases: "" beats "-rc.1".
|
|
100
|
+
if (pa.rest === "") return 1;
|
|
101
|
+
if (pb.rest === "") return -1;
|
|
102
|
+
return pa.rest < pb.rest ? -1 : 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The plugin's version from an `omp plugin list --json` payload.
|
|
106
|
+
*
|
|
107
|
+
* Tolerant by design, unlike conductor's own surface read: this plugin is a
|
|
108
|
+
* peer the fleet depends on, not the package being upgraded, so an inventory
|
|
109
|
+
* that cannot be parsed is `unreadable` rather than a thrown error that would
|
|
110
|
+
* take a whole `doctor` run down with it. */
|
|
111
|
+
export function installedFromInventory(raw: string): VersionRead {
|
|
112
|
+
let parsed: unknown;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw);
|
|
115
|
+
} catch {
|
|
116
|
+
return { kind: "unreadable", problem: "omp plugin list returned invalid JSON" };
|
|
117
|
+
}
|
|
118
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
119
|
+
return { kind: "unreadable", problem: "omp plugin list returned an invalid inventory" };
|
|
120
|
+
}
|
|
121
|
+
const npm = Reflect.get(parsed, "npm");
|
|
122
|
+
if (!Array.isArray(npm)) {
|
|
123
|
+
return { kind: "unreadable", problem: "omp plugin list returned no npm inventory" };
|
|
124
|
+
}
|
|
125
|
+
const entry = npm.find(
|
|
126
|
+
(e) => e !== null && typeof e === "object" && Reflect.get(e, "name") === TELEGRAM_PACKAGE,
|
|
127
|
+
);
|
|
128
|
+
if (entry === undefined) return { kind: "absent" };
|
|
129
|
+
const version = Reflect.get(entry, "version");
|
|
130
|
+
return typeof version === "string" && version.length > 0
|
|
131
|
+
? { kind: "version", version }
|
|
132
|
+
: { kind: "unreadable", problem: `${TELEGRAM_PACKAGE} is installed with no version` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Where the daemon writes its own identity. Resolved exactly the way every
|
|
136
|
+
* other reader of that directory resolves it, so a test's override works here
|
|
137
|
+
* without a second convention. */
|
|
138
|
+
export function telegramStateDir(stateDir?: string): string {
|
|
139
|
+
const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
140
|
+
return override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What the running daemon says it is. `absent` when it is not running — which
|
|
144
|
+
* is not a fault here: a fleet may have no daemon up, and that is `unknown`
|
|
145
|
+
* rather than stale. */
|
|
146
|
+
export function daemonVersion(stateDir?: string): VersionRead {
|
|
147
|
+
const path = join(telegramStateDir(stateDir), "daemon.json");
|
|
148
|
+
let raw: unknown;
|
|
149
|
+
try {
|
|
150
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
|
|
153
|
+
return {
|
|
154
|
+
kind: "unreadable",
|
|
155
|
+
problem: `cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
159
|
+
return { kind: "unreadable", problem: `${path} is not a daemon record` };
|
|
160
|
+
}
|
|
161
|
+
const version = Reflect.get(raw, "version");
|
|
162
|
+
return typeof version === "string" && version.length > 0
|
|
163
|
+
? { kind: "version", version }
|
|
164
|
+
: { kind: "unreadable", problem: `${path} records no version` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Decide, from three reads.
|
|
169
|
+
*
|
|
170
|
+
* Order matters, and it is worth stating: `installed-behind` is checked before
|
|
171
|
+
* `daemon-stale` because the remedy differs — a behind install needs an install
|
|
172
|
+
* *and* a restart, and reporting only the restart would leave the operator one
|
|
173
|
+
* step short. A daemon matching a behind install is therefore still reported as
|
|
174
|
+
* behind, not as stale.
|
|
175
|
+
*/
|
|
176
|
+
export function classifyTelegramFreshness(surfaces: TelegramSurfaces): TelegramFreshness {
|
|
177
|
+
const { installed, daemon, published } = surfaces;
|
|
178
|
+
|
|
179
|
+
if (installed.kind === "absent") {
|
|
180
|
+
return {
|
|
181
|
+
state: "not-installed",
|
|
182
|
+
surfaces,
|
|
183
|
+
detail: `${TELEGRAM_PACKAGE} is not installed; the reply-in-topic contract the brief mandates has no transport`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (installed.kind === "unreadable") {
|
|
187
|
+
return { state: "unknown", surfaces, detail: installed.problem };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (published.kind === "version" && compareVersions(installed.version, published.version) < 0) {
|
|
191
|
+
const running =
|
|
192
|
+
daemon.kind === "version" && daemon.version !== installed.version
|
|
193
|
+
? `; the daemon is on ${daemon.version}`
|
|
194
|
+
: "";
|
|
195
|
+
return {
|
|
196
|
+
state: "installed-behind",
|
|
197
|
+
surfaces,
|
|
198
|
+
detail:
|
|
199
|
+
`${TELEGRAM_PACKAGE} ${installed.version} is installed, ${published.version} is published${running}` +
|
|
200
|
+
` — install it and restart the daemon (a publish does not bump an installed copy)`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (daemon.kind === "version" && daemon.version !== installed.version) {
|
|
205
|
+
return {
|
|
206
|
+
state: "daemon-stale",
|
|
207
|
+
surfaces,
|
|
208
|
+
detail:
|
|
209
|
+
`the running ${TELEGRAM_PACKAGE} daemon is ${daemon.version} but ${installed.version} is installed` +
|
|
210
|
+
" — restart it, or it keeps serving the old code",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Nothing to compare against: the registry was unreachable, so "current" is
|
|
215
|
+
// an answer this check has not earned.
|
|
216
|
+
if (published.kind !== "version") {
|
|
217
|
+
return {
|
|
218
|
+
state: "unknown",
|
|
219
|
+
surfaces,
|
|
220
|
+
detail:
|
|
221
|
+
published.kind === "unreadable"
|
|
222
|
+
? `${TELEGRAM_PACKAGE} ${installed.version} is installed; the newest published version could not be read (${published.problem})`
|
|
223
|
+
: `${TELEGRAM_PACKAGE} ${installed.version} is installed; nothing published to compare against`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { state: "current", surfaces };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** What reading the surfaces needs. Injected so the whole check is testable
|
|
231
|
+
* without npm, a daemon, or a plugin install. */
|
|
232
|
+
export interface TelegramFreshnessDeps {
|
|
233
|
+
/** `omp plugin list --json`, and `npm view omp-telegram version`. */
|
|
234
|
+
run: (cmd: string, args: readonly string[]) => Promise<{ code: number; stdout: string }>;
|
|
235
|
+
/** Override for the daemon record's directory, in tests. */
|
|
236
|
+
stateDir?: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Read all three surfaces and classify. Never throws: every failure is a
|
|
240
|
+
* `VersionRead` that the classifier turns into `unknown`. */
|
|
241
|
+
export async function checkTelegramFreshness(
|
|
242
|
+
deps: TelegramFreshnessDeps,
|
|
243
|
+
): Promise<TelegramFreshness> {
|
|
244
|
+
const inventory = await deps
|
|
245
|
+
.run("omp", ["plugin", "list", "--json"])
|
|
246
|
+
.then((r): VersionRead =>
|
|
247
|
+
r.code === 0
|
|
248
|
+
? installedFromInventory(r.stdout)
|
|
249
|
+
: { kind: "unreadable", problem: "omp plugin list failed" },
|
|
250
|
+
)
|
|
251
|
+
.catch((err: unknown): VersionRead => ({
|
|
252
|
+
kind: "unreadable",
|
|
253
|
+
problem: `omp plugin list failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
254
|
+
}));
|
|
255
|
+
const published = await deps
|
|
256
|
+
.run("npm", ["view", TELEGRAM_PACKAGE, "version"])
|
|
257
|
+
.then((r): VersionRead => {
|
|
258
|
+
const version = r.stdout.trim();
|
|
259
|
+
return r.code === 0 && version.length > 0
|
|
260
|
+
? { kind: "version", version }
|
|
261
|
+
: { kind: "unreadable", problem: "npm registry unreachable" };
|
|
262
|
+
})
|
|
263
|
+
.catch((): VersionRead => ({ kind: "unreadable", problem: "npm registry unreachable" }));
|
|
264
|
+
return classifyTelegramFreshness({
|
|
265
|
+
installed: inventory,
|
|
266
|
+
daemon: daemonVersion(deps.stateDir),
|
|
267
|
+
published,
|
|
268
|
+
});
|
|
269
|
+
}
|
package/src/to-spec.ts
CHANGED
|
@@ -33,6 +33,13 @@
|
|
|
33
33
|
* "strict"` to the task tool. Conductor re-validates whatever the agent
|
|
34
34
|
* returned before persisting, because the harness is permissive by design
|
|
35
35
|
* and the store vets only what this module hands it.
|
|
36
|
+
*
|
|
37
|
+
* One contract, every surface (#883): this Zod schema is the single source of
|
|
38
|
+
* truth. `TO_SPEC_SCHEMA` generated from it stamps the native task launch,
|
|
39
|
+
* `parseToSpecResult` persists against it, and the drift test pins the
|
|
40
|
+
* rendered brief, the shipped agent role, and the floor's return contract to
|
|
41
|
+
* exactly these fields — a field the prose asks for that the schema refuses
|
|
42
|
+
* is a candidate recorded blocked for following instructions.
|
|
36
43
|
*/
|
|
37
44
|
|
|
38
45
|
import { z } from "zod";
|
|
@@ -96,6 +103,12 @@ const ToSpecResultSchema = z
|
|
|
96
103
|
.optional()
|
|
97
104
|
.describe("Required when `routing` is `MULTI`: what each slice goes to."),
|
|
98
105
|
source: ToSpecSourceSchema,
|
|
106
|
+
evidence: z
|
|
107
|
+
.array(z.string().trim().min(1))
|
|
108
|
+
.optional()
|
|
109
|
+
.describe(
|
|
110
|
+
"The files/symbols proving the verdict — required for ALREADY DONE (the code that already does the work, never a title match); welcome on every verdict.",
|
|
111
|
+
),
|
|
99
112
|
laterWorkInvalidates: z
|
|
100
113
|
.boolean()
|
|
101
114
|
.describe("Whether later work (an epic committed after this candidate was filed) invalidated its premise."),
|
|
@@ -126,8 +139,10 @@ const ToSpecResultSchema = z
|
|
|
126
139
|
.min(1)
|
|
127
140
|
.describe("The files/dirs this slice writes — the file lane that serialises concurrent work."),
|
|
128
141
|
dependencies: z
|
|
129
|
-
.array(z.string().trim().min(1))
|
|
130
|
-
.describe(
|
|
142
|
+
.array(z.union([z.string().trim().min(1), z.number().int().min(1)]))
|
|
143
|
+
.describe(
|
|
144
|
+
'Open prerequisites this work is blocked on, as bare issue numbers (875) or strings ("875"); empty when none.',
|
|
145
|
+
),
|
|
131
146
|
proposedBrief: z
|
|
132
147
|
.string()
|
|
133
148
|
.trim()
|
|
@@ -159,6 +174,12 @@ const ToSpecResultSchema = z
|
|
|
159
174
|
ctx.addIssue({ code: "custom", message: `${verdict} must not carry proposedBrief` });
|
|
160
175
|
}
|
|
161
176
|
}
|
|
177
|
+
if (verdict === "ALREADY DONE" && (value.evidence === undefined || value.evidence.length === 0)) {
|
|
178
|
+
ctx.addIssue({
|
|
179
|
+
code: "custom",
|
|
180
|
+
message: "ALREADY DONE requires evidence naming the file/symbol that already does the work",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
162
183
|
if (value.routing === MULTI_ROUTING && value.routingSplit === undefined) {
|
|
163
184
|
ctx.addIssue({ code: "custom", message: 'routing "MULTI" requires routingSplit' });
|
|
164
185
|
}
|
|
@@ -385,3 +406,30 @@ export function parseToSpecEvidence(evidence: string): ToSpecResult | undefined
|
|
|
385
406
|
const parsed = ToSpecResultSchema.safeParse(raw.result);
|
|
386
407
|
return parsed.success ? parsed.data : undefined;
|
|
387
408
|
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The mirror of {@link parseToSpecEvidence} for the refusal payload
|
|
412
|
+
* {@link failureEvidence} writes: the recorded failure when a row is a
|
|
413
|
+
* refused pass, `undefined` for anything else (a valid verdict, a mechanical
|
|
414
|
+
* hold, an in-flight marker, a hand-edited row).
|
|
415
|
+
*
|
|
416
|
+
* A refusal is durable evidence too — it says a full delegated batch was
|
|
417
|
+
* already spent on this candidate and produced nothing usable — so the
|
|
418
|
+
* selection side needs to read it back rather than seeing only "not groomed"
|
|
419
|
+
* and re-spending immediately (#887).
|
|
420
|
+
*/
|
|
421
|
+
export function parseToSpecFailureEvidence(evidence: string): ToSpecFailure | undefined {
|
|
422
|
+
let raw: unknown;
|
|
423
|
+
try {
|
|
424
|
+
raw = JSON.parse(evidence);
|
|
425
|
+
} catch {
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
if (!isObject(raw) || raw.kind !== "to-spec-failure") return undefined;
|
|
429
|
+
const failure = raw.failure;
|
|
430
|
+
if (!isObject(failure) || typeof failure.detail !== "string") return undefined;
|
|
431
|
+
if (failure.kind !== "malformed" && failure.kind !== "missing-source" && failure.kind !== "stale-source") {
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
return { kind: failure.kind, detail: failure.detail };
|
|
435
|
+
}
|