dsh-plugin-compat-check 0.1.3 → 0.1.5
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 +36 -20
- package/THIRD_PARTY_NOTICES.md +139 -0
- package/index.js +288 -55
- package/kernel/lib/author-ci-output.mjs +188 -0
- package/kernel/lib/author-ci-policy.mjs +285 -0
- package/kernel/lib/catalog.mjs +38 -0
- package/kernel/lib/contracts.mjs +88 -0
- package/kernel/lib/dependency-graph.mjs +650 -0
- package/kernel/lib/extract.mjs +14 -0
- package/kernel/lib/finding-guidance.mjs +72 -0
- package/kernel/lib/finding-review-store.mjs +245 -0
- package/kernel/lib/finding-review.mjs +338 -0
- package/kernel/lib/functional-health-contract.mjs +337 -0
- package/kernel/lib/issue-report.mjs +217 -0
- package/kernel/lib/observation.mjs +19 -9
- package/kernel/lib/owned-process.mjs +287 -0
- package/kernel/lib/report-identity-worker.mjs +153 -0
- package/kernel/lib/report-identity.mjs +15 -0
- package/kernel/lib/report-service.mjs +196 -11
- package/kernel/lib/report.mjs +76 -11
- package/kernel/lib/source-analysis.mjs +24 -6
- package/kernel/lib/upgrade-capture-worker.mjs +20 -0
- package/kernel/lib/upgrade-cleanup-worker.mjs +28 -0
- package/kernel/lib/upgrade-contract.mjs +471 -0
- package/kernel/lib/upgrade-input.mjs +309 -0
- package/kernel/lib/upgrade-preview.mjs +256 -0
- package/kernel/lib/upgrade-worker.mjs +50 -0
- package/kernel/lib/verdict.mjs +8 -1
- package/kernel/lib/version.mjs +1 -1
- package/kernel/lib/worker-analysis.mjs +16 -1
- package/kernel/lib/workflow-panels.mjs +706 -0
- package/kernel/rules/README.md +8 -6
- package/lib/client.js +2974 -69
- package/lib/dependency-graph.js +651 -0
- package/lib/finding-guidance.js +73 -0
- package/lib/finding-review.js +339 -0
- package/lib/functional-health-contract.js +338 -0
- package/lib/report-contract.js +88 -0
- package/lib/upgrade-contract.js +472 -0
- package/lib/workflow-panels.js +707 -0
- package/package.json +5 -3
- package/scripts/prepublish-check.mjs +28 -13
- package/scripts/stage-contract.mjs +91 -29
- package/scripts/stage-kernel.mjs +4 -3
- package/skill/SKILL.md +92 -39
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/** Owned process-group lifecycle. No argv, environment or global process listing
|
|
2
|
+
* is retained; ps is a bounded failure diagnostic, filtered to this group. */
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { privateDiagnostic } from "../plugin/lib/host-resources.js";
|
|
7
|
+
export function failureDiagnostic(error) {
|
|
8
|
+
return error?.diagnostics?.length
|
|
9
|
+
? error.diagnostics
|
|
10
|
+
: error?.diagnostic
|
|
11
|
+
? [error.diagnostic]
|
|
12
|
+
: [];
|
|
13
|
+
}
|
|
14
|
+
export function publishDiagnostic(diagnostic, privateDetails) {
|
|
15
|
+
const dir = process.env.DSC_EXECUTION_DIAGNOSTICS;
|
|
16
|
+
if (dir) {
|
|
17
|
+
const redact = (value) =>
|
|
18
|
+
String(value)
|
|
19
|
+
.slice(-8192)
|
|
20
|
+
.replace(/https?:\/\/[^\s"'<>]+/gi, "<url>")
|
|
21
|
+
.replace(
|
|
22
|
+
/(authorization\s*[:=]\s*)(?:Bearer\s+)?[^\s,;]+/gi,
|
|
23
|
+
"$1[redacted]",
|
|
24
|
+
)
|
|
25
|
+
.replace(
|
|
26
|
+
/((?:api[_-]?key|access[_-]?token|password)\s*[:=]\s*)[^\s,;]+/gi,
|
|
27
|
+
"$1[redacted]",
|
|
28
|
+
);
|
|
29
|
+
const data = {
|
|
30
|
+
...diagnostic,
|
|
31
|
+
...(privateDetails
|
|
32
|
+
? {
|
|
33
|
+
privateDetails: Object.fromEntries(
|
|
34
|
+
Object.entries(privateDetails).map(([key, value]) => [
|
|
35
|
+
key,
|
|
36
|
+
redact(value),
|
|
37
|
+
]),
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
: {}),
|
|
41
|
+
};
|
|
42
|
+
const written = privateDiagnostic(
|
|
43
|
+
path.join(dir, `${diagnostic.component}-${randomUUID()}.json`),
|
|
44
|
+
data,
|
|
45
|
+
);
|
|
46
|
+
diagnostic.persistence = { requested: true, written };
|
|
47
|
+
}
|
|
48
|
+
return diagnostic;
|
|
49
|
+
}
|
|
50
|
+
export function groupSnapshot(pgid) {
|
|
51
|
+
if (process.platform === "win32")
|
|
52
|
+
return { available: false, reason: "unsupported-platform" };
|
|
53
|
+
const args =
|
|
54
|
+
process.platform === "darwin"
|
|
55
|
+
? ["-g", String(pgid), "-o", "pid=,ppid=,pgid=,uid=,stat="]
|
|
56
|
+
: ["-axo", "pid=,ppid=,pgid=,uid=,stat="];
|
|
57
|
+
const r = spawnSync("/bin/ps", args, {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
timeout: 1000,
|
|
60
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
61
|
+
});
|
|
62
|
+
if (
|
|
63
|
+
process.platform === "darwin" &&
|
|
64
|
+
r.status === 1 &&
|
|
65
|
+
!r.error &&
|
|
66
|
+
!r.stdout.trim() &&
|
|
67
|
+
!r.stderr.trim()
|
|
68
|
+
)
|
|
69
|
+
return { available: true, members: [], truncated: false };
|
|
70
|
+
if (r.status !== 0 || r.error)
|
|
71
|
+
return { available: false, reason: r.error?.code || "ps-failed" };
|
|
72
|
+
const lines = r.stdout.trim().split("\n");
|
|
73
|
+
const members = [];
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
|
|
76
|
+
if (!m) return { available: false, reason: "invalid-ps-output" };
|
|
77
|
+
if (Number(m[3]) === pgid)
|
|
78
|
+
members.push({
|
|
79
|
+
pid: Number(m[1]),
|
|
80
|
+
ppid: Number(m[2]),
|
|
81
|
+
pgid: Number(m[3]),
|
|
82
|
+
uid: Number(m[4]),
|
|
83
|
+
state: m[5],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
available: true,
|
|
88
|
+
members: members.slice(0, 64),
|
|
89
|
+
truncated: members.length > 64,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const stopping = new WeakMap();
|
|
93
|
+
export function stopOwnedChild(child) {
|
|
94
|
+
if (!child?.pid)
|
|
95
|
+
return Promise.resolve({ stopped: true, status: "not-started" });
|
|
96
|
+
if (stopping.has(child)) return stopping.get(child);
|
|
97
|
+
const pending = (async () => {
|
|
98
|
+
if (!Number.isSafeInteger(child.pid) || child.pid <= 1)
|
|
99
|
+
throw new Error("Invalid owned child pid");
|
|
100
|
+
const done = () => child.exitCode !== null || child.signalCode !== null;
|
|
101
|
+
let exitListener;
|
|
102
|
+
const exited = done()
|
|
103
|
+
? Promise.resolve()
|
|
104
|
+
: new Promise((resolve) => {
|
|
105
|
+
exitListener = resolve;
|
|
106
|
+
child.once("exit", resolve);
|
|
107
|
+
});
|
|
108
|
+
const signals = [];
|
|
109
|
+
let phase = "SIGTERM";
|
|
110
|
+
const state = () => ({
|
|
111
|
+
schemaVersion: 1,
|
|
112
|
+
kind: "plugin-compat-check/execution-diagnostic",
|
|
113
|
+
component: "owned-process",
|
|
114
|
+
phase,
|
|
115
|
+
pid: child.pid,
|
|
116
|
+
pgid: process.platform === "win32" ? null : child.pid,
|
|
117
|
+
parentPid: process.pid,
|
|
118
|
+
uid: process.getuid?.() ?? null,
|
|
119
|
+
leaderExited: done(),
|
|
120
|
+
exitCode: child.exitCode,
|
|
121
|
+
signalCode: child.signalCode,
|
|
122
|
+
signals,
|
|
123
|
+
});
|
|
124
|
+
const waitExit = async () => {
|
|
125
|
+
let timer;
|
|
126
|
+
try {
|
|
127
|
+
await Promise.race([
|
|
128
|
+
exited,
|
|
129
|
+
new Promise((resolve) => {
|
|
130
|
+
timer = setTimeout(resolve, 2000);
|
|
131
|
+
}),
|
|
132
|
+
]);
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const signal = async (sig) => {
|
|
138
|
+
phase = sig;
|
|
139
|
+
try {
|
|
140
|
+
if (process.platform !== "win32") process.kill(-child.pid, sig);
|
|
141
|
+
else if (!done()) child.kill(sig);
|
|
142
|
+
signals.push({ signal: sig, result: "sent" });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
signals.push({ signal: sig, result: error.code || "error" });
|
|
145
|
+
if (error.code === "ESRCH") return;
|
|
146
|
+
let group = groupSnapshot(child.pid);
|
|
147
|
+
if (
|
|
148
|
+
process.platform === "darwin" &&
|
|
149
|
+
error.code === "EPERM" &&
|
|
150
|
+
!done() &&
|
|
151
|
+
group.available &&
|
|
152
|
+
!group.truncated &&
|
|
153
|
+
group.members.every((m) => m.uid === process.getuid?.()) &&
|
|
154
|
+
(sig === "SIGKILL" ||
|
|
155
|
+
group.members.every(
|
|
156
|
+
(m) => m.state.startsWith("Z") || m.state.includes("E"),
|
|
157
|
+
))
|
|
158
|
+
) {
|
|
159
|
+
await waitExit();
|
|
160
|
+
group = groupSnapshot(child.pid);
|
|
161
|
+
}
|
|
162
|
+
// Darwin filters zombies out of killpg and can return EPERM when only
|
|
163
|
+
// zombies remain. Never infer this from EPERM alone or a failed ps.
|
|
164
|
+
const zombieOnly =
|
|
165
|
+
process.platform === "darwin" &&
|
|
166
|
+
error.code === "EPERM" &&
|
|
167
|
+
group.available &&
|
|
168
|
+
(done() ||
|
|
169
|
+
group.members.some(
|
|
170
|
+
(m) => m.pid === child.pid && m.state.startsWith("Z"),
|
|
171
|
+
)) &&
|
|
172
|
+
!group.truncated &&
|
|
173
|
+
(group.members.length > 0 || done()) &&
|
|
174
|
+
group.members.every(
|
|
175
|
+
(m) => m.uid === process.getuid?.() && m.state.startsWith("Z"),
|
|
176
|
+
);
|
|
177
|
+
if (zombieOnly) {
|
|
178
|
+
signals[signals.length - 1].result = group.members.length
|
|
179
|
+
? "verified-zombie-only"
|
|
180
|
+
: "verified-exited-group";
|
|
181
|
+
signals[signals.length - 1].group = group;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const failure = new Error(
|
|
185
|
+
`Owned process cleanup failed: ${sig} ${error.code || "error"}`,
|
|
186
|
+
{ cause: error },
|
|
187
|
+
);
|
|
188
|
+
failure.code = "OWNED_PROCESS_CLEANUP_FAILED";
|
|
189
|
+
failure.diagnostic = publishDiagnostic({
|
|
190
|
+
...state(),
|
|
191
|
+
code: failure.code,
|
|
192
|
+
signalError: {
|
|
193
|
+
code: error.code || null,
|
|
194
|
+
syscall: error.syscall || null,
|
|
195
|
+
stack: String(error.stack || "")
|
|
196
|
+
.split("\n")
|
|
197
|
+
.slice(1, 10)
|
|
198
|
+
.map((line) =>
|
|
199
|
+
line
|
|
200
|
+
.replace(/\((?:file:\/\/)?\/.*\/([^/]+:\d+:\d+)\)/g, "($1)")
|
|
201
|
+
.replace(/at (?:file:\/\/)?\/.*\/([^/]+:\d+:\d+)/g, "at $1"),
|
|
202
|
+
),
|
|
203
|
+
},
|
|
204
|
+
group,
|
|
205
|
+
});
|
|
206
|
+
throw failure;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
await signal("SIGTERM");
|
|
211
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
212
|
+
await signal("SIGKILL");
|
|
213
|
+
phase = "exit";
|
|
214
|
+
let timer;
|
|
215
|
+
try {
|
|
216
|
+
await Promise.race([
|
|
217
|
+
exited,
|
|
218
|
+
new Promise((_, reject) => {
|
|
219
|
+
timer = setTimeout(
|
|
220
|
+
() =>
|
|
221
|
+
reject(
|
|
222
|
+
Object.assign(
|
|
223
|
+
new Error("Owned process did not exit after SIGKILL"),
|
|
224
|
+
{
|
|
225
|
+
code: "OWNED_PROCESS_EXIT_TIMEOUT",
|
|
226
|
+
diagnostic: publishDiagnostic({
|
|
227
|
+
...state(),
|
|
228
|
+
code: "OWNED_PROCESS_EXIT_TIMEOUT",
|
|
229
|
+
group: groupSnapshot(child.pid),
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
),
|
|
233
|
+
),
|
|
234
|
+
2000,
|
|
235
|
+
);
|
|
236
|
+
}),
|
|
237
|
+
]);
|
|
238
|
+
} finally {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
stopped: true,
|
|
243
|
+
status: signals.some((s) => s.result === "verified-zombie-only")
|
|
244
|
+
? "zombie-only"
|
|
245
|
+
: "stopped",
|
|
246
|
+
...state(),
|
|
247
|
+
};
|
|
248
|
+
} catch (error) {
|
|
249
|
+
child.unref?.();
|
|
250
|
+
throw error;
|
|
251
|
+
} finally {
|
|
252
|
+
if (exitListener) child.off("exit", exitListener);
|
|
253
|
+
child.stdout?.destroy();
|
|
254
|
+
child.stderr?.destroy();
|
|
255
|
+
}
|
|
256
|
+
})();
|
|
257
|
+
stopping.set(child, pending);
|
|
258
|
+
return pending;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Attempt each owned cleanup even when another resource fails. */
|
|
262
|
+
export async function cleanupOwned(tasks) {
|
|
263
|
+
const settled = await Promise.allSettled(
|
|
264
|
+
tasks.map((task) => Promise.resolve().then(task)),
|
|
265
|
+
);
|
|
266
|
+
const failures = settled
|
|
267
|
+
.filter((r) => r.status === "rejected")
|
|
268
|
+
.map((r) => r.reason);
|
|
269
|
+
if (failures.length) {
|
|
270
|
+
const error = new AggregateError(failures, "Owned resource cleanup failed");
|
|
271
|
+
error.code = "OWNED_RESOURCE_CLEANUP_FAILED";
|
|
272
|
+
error.diagnostics = failures.flatMap(failureDiagnostic);
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export function combineFailure(primary, cleanup) {
|
|
277
|
+
if (!primary) return cleanup;
|
|
278
|
+
const error = new Error(primary.message + "; " + cleanup.message, {
|
|
279
|
+
cause: primary,
|
|
280
|
+
});
|
|
281
|
+
error.code = primary.code || "EXECUTION_FAILED";
|
|
282
|
+
error.diagnostics = [
|
|
283
|
+
...failureDiagnostic(primary),
|
|
284
|
+
...failureDiagnostic(cleanup),
|
|
285
|
+
];
|
|
286
|
+
return error;
|
|
287
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
5
|
+
const hash = createHash("sha256");
|
|
6
|
+
const add = (value) => hash.update(JSON.stringify(value) + "\n");
|
|
7
|
+
let remaining = 50000,
|
|
8
|
+
directories = 10000;
|
|
9
|
+
const services = new Set(),
|
|
10
|
+
manifests = [];
|
|
11
|
+
function metadata(file) {
|
|
12
|
+
if (--remaining < 0) throw Error("缓存身份文件预算超限,等待后续刷新");
|
|
13
|
+
const stat = fs.statSync(file);
|
|
14
|
+
add([
|
|
15
|
+
file,
|
|
16
|
+
fs.realpathSync(file),
|
|
17
|
+
stat.size,
|
|
18
|
+
stat.mtimeMs,
|
|
19
|
+
stat.ctimeMs,
|
|
20
|
+
stat.ino,
|
|
21
|
+
stat.mode,
|
|
22
|
+
]);
|
|
23
|
+
return stat;
|
|
24
|
+
}
|
|
25
|
+
function smallFile(file, limit = 1024 * 1024) {
|
|
26
|
+
let fd;
|
|
27
|
+
try {
|
|
28
|
+
metadata(file);
|
|
29
|
+
// Open nonblocking before inspecting the same descriptor. A swapped
|
|
30
|
+
// symlink or FIFO cannot turn the following bounded read into a host hang.
|
|
31
|
+
fd = fs.openSync(
|
|
32
|
+
file,
|
|
33
|
+
fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0),
|
|
34
|
+
);
|
|
35
|
+
const stat = fs.fstatSync(fd);
|
|
36
|
+
if (!stat.isFile() || stat.size > limit)
|
|
37
|
+
throw Error("缓存身份输入不是预算内普通文件");
|
|
38
|
+
const buffer = Buffer.alloc(limit + 1),
|
|
39
|
+
length = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
40
|
+
if (length > limit) throw Error("缓存身份输入超过读取预算");
|
|
41
|
+
const bytes = buffer.subarray(0, length);
|
|
42
|
+
add(bytes.toString("base64"));
|
|
43
|
+
return bytes;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
|
|
46
|
+
add([file, "missing"]);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
} finally {
|
|
51
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function walk(root) {
|
|
56
|
+
const seen = new Set(),
|
|
57
|
+
queue = [root],
|
|
58
|
+
canonical = fs.realpathSync(root);
|
|
59
|
+
while (queue.length) {
|
|
60
|
+
if (--directories < 0) throw Error("缓存身份目录预算超限,等待后续刷新");
|
|
61
|
+
const dir = queue.pop(),
|
|
62
|
+
real = fs.realpathSync(dir);
|
|
63
|
+
if (
|
|
64
|
+
seen.has(real) ||
|
|
65
|
+
(real !== canonical && !real.startsWith(canonical + path.sep))
|
|
66
|
+
)
|
|
67
|
+
continue;
|
|
68
|
+
seen.add(real);
|
|
69
|
+
for (const entry of fs.readdirSync(dir).sort()) {
|
|
70
|
+
const file = path.join(dir, entry);
|
|
71
|
+
let stat;
|
|
72
|
+
try {
|
|
73
|
+
stat = metadata(file);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code === "ENOENT") {
|
|
76
|
+
add([file, "missing"]);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
if (stat.isDirectory()) queue.push(file);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const { nodeModules, files = [], context = {} } = workerData;
|
|
87
|
+
add(context);
|
|
88
|
+
metadata(nodeModules);
|
|
89
|
+
for (const name of fs.readdirSync(nodeModules).sort()) {
|
|
90
|
+
const bases = name.startsWith("@")
|
|
91
|
+
? fs
|
|
92
|
+
.readdirSync(path.join(nodeModules, name))
|
|
93
|
+
.sort()
|
|
94
|
+
.map((n) => path.join(name, n))
|
|
95
|
+
: [name];
|
|
96
|
+
for (const base of bases) {
|
|
97
|
+
const root = path.join(nodeModules, base);
|
|
98
|
+
let bytes;
|
|
99
|
+
try {
|
|
100
|
+
bytes = smallFile(path.join(root, "package.json"), 65536);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (remaining < 0) throw error;
|
|
103
|
+
add([root, "unreadable-manifest", error.code || error.message]);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!bytes) continue;
|
|
107
|
+
let manifest;
|
|
108
|
+
try {
|
|
109
|
+
manifest = JSON.parse(bytes);
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
!manifest ||
|
|
115
|
+
typeof manifest !== "object" ||
|
|
116
|
+
Array.isArray(manifest) ||
|
|
117
|
+
!("dsh" in manifest) ||
|
|
118
|
+
String(manifest.name || "").startsWith("@deepseek-ai/")
|
|
119
|
+
)
|
|
120
|
+
continue;
|
|
121
|
+
manifests.push({ name: manifest.name });
|
|
122
|
+
const inject = manifest.inject;
|
|
123
|
+
for (const name of Array.isArray(inject)
|
|
124
|
+
? inject
|
|
125
|
+
: inject && typeof inject === "object"
|
|
126
|
+
? [
|
|
127
|
+
...(Array.isArray(inject.required) ? inject.required : []),
|
|
128
|
+
...(Array.isArray(inject.optional) ? inject.optional : []),
|
|
129
|
+
]
|
|
130
|
+
: [])
|
|
131
|
+
if (typeof name === "string") services.add(name);
|
|
132
|
+
walk(root);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const file of files.filter(Boolean)) {
|
|
136
|
+
const bytes = smallFile(file, 8 * 1024 * 1024);
|
|
137
|
+
if (bytes && path.basename(file).startsWith("dsh-api-catalog"))
|
|
138
|
+
try {
|
|
139
|
+
for (const name of Object.keys(JSON.parse(bytes).host || {}))
|
|
140
|
+
services.add(name);
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
parentPort.postMessage({
|
|
144
|
+
ok: true,
|
|
145
|
+
value: {
|
|
146
|
+
digest: hash.digest("hex"),
|
|
147
|
+
services: [...services].sort(),
|
|
148
|
+
manifests,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
parentPort.postMessage({ ok: false, error: error.message });
|
|
153
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { runWorkerTask } from "./worker-analysis.mjs";
|
|
2
|
+
/** Bounded metadata walk runs off the host thread and never imports plugin code. */
|
|
3
|
+
export function reportIdentity(inputs) {
|
|
4
|
+
return runWorkerTask(
|
|
5
|
+
new URL("./report-identity-worker.mjs", import.meta.url),
|
|
6
|
+
inputs,
|
|
7
|
+
{
|
|
8
|
+
timeoutMs: 5000,
|
|
9
|
+
resourceLimits: {
|
|
10
|
+
maxOldGenerationSizeMb: 128,
|
|
11
|
+
maxYoungGenerationSizeMb: 16,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
/** Single flight per target;
|
|
1
|
+
/** Single flight per canonical target; keep valid reports while inputs refresh. */
|
|
2
2
|
export class ReportService {
|
|
3
|
-
constructor(
|
|
3
|
+
constructor(
|
|
4
|
+
compute,
|
|
5
|
+
{ ttlMs = 300000, clock = Date.now, identity = null, watchMs = 5000 } = {},
|
|
6
|
+
) {
|
|
4
7
|
this.compute = compute;
|
|
5
8
|
this.ttlMs = ttlMs;
|
|
6
9
|
this.clock = clock;
|
|
10
|
+
this.identity = identity;
|
|
11
|
+
this.watchMs = watchMs;
|
|
7
12
|
this.states = new Map();
|
|
13
|
+
this.disposed = false;
|
|
8
14
|
}
|
|
9
15
|
state(key) {
|
|
10
16
|
if (!this.states.has(key))
|
|
@@ -13,43 +19,222 @@ export class ReportService {
|
|
|
13
19
|
at: 0,
|
|
14
20
|
inflight: null,
|
|
15
21
|
error: null,
|
|
22
|
+
generation: 0,
|
|
23
|
+
stale: false,
|
|
24
|
+
identity: undefined,
|
|
25
|
+
checking: null,
|
|
26
|
+
options: {},
|
|
27
|
+
timer: null,
|
|
16
28
|
});
|
|
17
29
|
return this.states.get(key);
|
|
18
30
|
}
|
|
31
|
+
invalidate(key, reason = "报告输入已变化,等待刷新") {
|
|
32
|
+
if (this.disposed) return;
|
|
33
|
+
const state = this.state(key);
|
|
34
|
+
state.generation++;
|
|
35
|
+
state.stale = true;
|
|
36
|
+
state.reason = reason;
|
|
37
|
+
state.error = null;
|
|
38
|
+
if (!state.inflight) void this.start(state, state.options);
|
|
39
|
+
}
|
|
40
|
+
notify() {
|
|
41
|
+
if (this.disposed || this.notifying) return;
|
|
42
|
+
this.notifying = Promise.resolve()
|
|
43
|
+
.then(async () => {
|
|
44
|
+
for (const state of this.states.values()) {
|
|
45
|
+
try {
|
|
46
|
+
await this.observe(state);
|
|
47
|
+
if (!this.disposed && state.stale)
|
|
48
|
+
void this.start(state, state.options);
|
|
49
|
+
} catch {
|
|
50
|
+
/* Keep the valid report and let the watchdog retry. */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
.finally(() => {
|
|
55
|
+
this.notifying = null;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async observe(state) {
|
|
59
|
+
if (!this.identity || this.disposed) return;
|
|
60
|
+
if (state.checking) return state.checking;
|
|
61
|
+
state.checking = Promise.resolve()
|
|
62
|
+
.then(() => this.identity(state.options))
|
|
63
|
+
.then((identity) => {
|
|
64
|
+
if (this.disposed) return;
|
|
65
|
+
if (state.identity !== undefined && state.identity !== identity) {
|
|
66
|
+
state.generation++;
|
|
67
|
+
state.stale = true;
|
|
68
|
+
state.reason = "报告输入已变化,等待刷新";
|
|
69
|
+
state.error = null;
|
|
70
|
+
}
|
|
71
|
+
state.identity = identity;
|
|
72
|
+
})
|
|
73
|
+
.catch((error) => {
|
|
74
|
+
if (!this.disposed) {
|
|
75
|
+
state.generation++;
|
|
76
|
+
state.stale = true;
|
|
77
|
+
state.error = error.message;
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
})
|
|
81
|
+
.finally(() => {
|
|
82
|
+
state.checking = null;
|
|
83
|
+
});
|
|
84
|
+
return state.checking;
|
|
85
|
+
}
|
|
86
|
+
watch(state) {
|
|
87
|
+
if (!this.identity || state.watching || this.disposed) return;
|
|
88
|
+
state.watching = true;
|
|
89
|
+
const schedule = () => {
|
|
90
|
+
if (this.disposed) return;
|
|
91
|
+
state.timer = setTimeout(async () => {
|
|
92
|
+
state.timer = null;
|
|
93
|
+
try {
|
|
94
|
+
await this.observe(state);
|
|
95
|
+
if (
|
|
96
|
+
!this.disposed &&
|
|
97
|
+
(state.stale || this.clock() - state.at > this.ttlMs)
|
|
98
|
+
)
|
|
99
|
+
void this.start(state, state.options);
|
|
100
|
+
} catch {
|
|
101
|
+
/* Expose observation errors and retry at the next bounded tick. */
|
|
102
|
+
}
|
|
103
|
+
schedule();
|
|
104
|
+
}, this.watchMs);
|
|
105
|
+
state.timer.unref?.();
|
|
106
|
+
};
|
|
107
|
+
schedule();
|
|
108
|
+
}
|
|
19
109
|
start(state, options) {
|
|
110
|
+
if (this.disposed)
|
|
111
|
+
return Promise.resolve({
|
|
112
|
+
ok: false,
|
|
113
|
+
error: "Plugin generation disposed / 已卸载",
|
|
114
|
+
});
|
|
20
115
|
if (state.inflight) return state.inflight;
|
|
116
|
+
state.options = options;
|
|
117
|
+
let generation;
|
|
21
118
|
state.inflight = Promise.resolve()
|
|
22
|
-
.then(() =>
|
|
23
|
-
|
|
119
|
+
.then(async () => {
|
|
120
|
+
if (this.identity) await this.observe(state);
|
|
121
|
+
generation = state.generation;
|
|
122
|
+
let commit;
|
|
123
|
+
const guard = {
|
|
124
|
+
commit: (fn) => {
|
|
125
|
+
commit = fn;
|
|
126
|
+
},
|
|
127
|
+
isCurrent: async () => {
|
|
128
|
+
if (this.disposed || generation !== state.generation) return false;
|
|
129
|
+
// A watchdog observation may have sampled before computation
|
|
130
|
+
// finished. Drain it, then start a fresh check for this commit.
|
|
131
|
+
if (state.checking) await state.checking;
|
|
132
|
+
if (this.disposed || generation !== state.generation) return false;
|
|
133
|
+
await this.observe(state);
|
|
134
|
+
return !this.disposed && generation === state.generation;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
const out = await this.compute(options, guard);
|
|
138
|
+
if (!(await guard.isCurrent()))
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
invalidated: true,
|
|
142
|
+
error: "报告输入已变化,正在重新检查",
|
|
143
|
+
};
|
|
24
144
|
if (out?.ok === true) {
|
|
145
|
+
commit?.();
|
|
25
146
|
state.report = out;
|
|
147
|
+
state.publishedIdentity = state.identity;
|
|
26
148
|
state.at = this.clock();
|
|
27
149
|
state.error = null;
|
|
28
|
-
|
|
150
|
+
state.stale = false;
|
|
151
|
+
state.reason = null;
|
|
152
|
+
} else {
|
|
153
|
+
state.error = out?.error || "体检未产生有效报告";
|
|
154
|
+
state.stale = true;
|
|
155
|
+
}
|
|
29
156
|
return out;
|
|
30
157
|
})
|
|
31
158
|
.catch((error) => {
|
|
32
|
-
|
|
33
|
-
|
|
159
|
+
if (!this.disposed) {
|
|
160
|
+
state.error = error.message;
|
|
161
|
+
state.stale = true;
|
|
162
|
+
}
|
|
163
|
+
return { ok: false, error: error.message };
|
|
34
164
|
})
|
|
35
165
|
.finally(() => {
|
|
36
166
|
state.inflight = null;
|
|
167
|
+
if (
|
|
168
|
+
!this.disposed &&
|
|
169
|
+
generation !== undefined &&
|
|
170
|
+
generation !== state.generation &&
|
|
171
|
+
!state.error
|
|
172
|
+
)
|
|
173
|
+
void this.start(state, state.options);
|
|
37
174
|
});
|
|
38
175
|
return state.inflight;
|
|
39
176
|
}
|
|
40
177
|
async get(key, options = {}, force = false) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
178
|
+
if (this.disposed)
|
|
179
|
+
return { ok: false, error: "Plugin generation disposed / 已卸载" };
|
|
180
|
+
const state = this.state(key);
|
|
181
|
+
state.options = options;
|
|
182
|
+
this.watch(state);
|
|
183
|
+
const age = this.clock() - state.at;
|
|
184
|
+
if (force || !state.report) {
|
|
185
|
+
// A force request can join a background flight which was superseded
|
|
186
|
+
// while extracting. Wait for the replacement generation, with a bound
|
|
187
|
+
// for continuously changing inputs rather than publishing old success.
|
|
188
|
+
let out;
|
|
189
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
190
|
+
out = await this.start(state, options);
|
|
191
|
+
if (!out?.invalidated || this.disposed) return out;
|
|
192
|
+
}
|
|
193
|
+
return { ok: false, error: "报告输入持续变化,请稍后重试" };
|
|
194
|
+
}
|
|
195
|
+
if ((state.stale && !state.error) || age > this.ttlMs)
|
|
196
|
+
void this.start(state, options);
|
|
45
197
|
return {
|
|
46
198
|
...state.report,
|
|
47
199
|
cache: {
|
|
48
200
|
hit: true,
|
|
49
201
|
ageMs: age,
|
|
50
202
|
refreshing: !!state.inflight,
|
|
203
|
+
stale: state.stale,
|
|
51
204
|
refreshError: state.error,
|
|
205
|
+
reason: state.reason || null,
|
|
52
206
|
},
|
|
53
207
|
};
|
|
54
208
|
}
|
|
209
|
+
/** The immutable identity observed for this published report, including retained stale reports. */
|
|
210
|
+
identityForReport(key, report) {
|
|
211
|
+
const state = this.states.get(key);
|
|
212
|
+
return state?.report?.report === report
|
|
213
|
+
? state.publishedIdentity
|
|
214
|
+
: undefined;
|
|
215
|
+
}
|
|
216
|
+
/** Validate a sidecar edit against the published report without recomputing or advancing its baseline. */
|
|
217
|
+
async isCurrent(key, report) {
|
|
218
|
+
const state = this.states.get(key);
|
|
219
|
+
const matches = () =>
|
|
220
|
+
!this.disposed &&
|
|
221
|
+
state &&
|
|
222
|
+
!state.stale &&
|
|
223
|
+
!state.error &&
|
|
224
|
+
state.report?.report === report;
|
|
225
|
+
if (!matches()) return false;
|
|
226
|
+
const generation = state.generation;
|
|
227
|
+
if (state.checking) await state.checking;
|
|
228
|
+
if (!matches() || generation !== state.generation) return false;
|
|
229
|
+
await this.observe(state);
|
|
230
|
+
return matches() && generation === state.generation;
|
|
231
|
+
}
|
|
232
|
+
dispose() {
|
|
233
|
+
this.disposed = true;
|
|
234
|
+
for (const state of this.states.values()) {
|
|
235
|
+
clearTimeout(state.timer);
|
|
236
|
+
state.timer = null;
|
|
237
|
+
state.generation++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
55
240
|
}
|