@kylecheng3146/agent-ops 0.0.1
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/LICENSE +21 -0
- package/README.md +83 -0
- package/SECURITY.md +22 -0
- package/dist/packages/cli/src/args.js +322 -0
- package/dist/packages/cli/src/bin.js +290 -0
- package/dist/packages/cli/src/cli.js +80 -0
- package/dist/packages/cli/src/commands/config.js +8 -0
- package/dist/packages/cli/src/commands/doctor.js +32 -0
- package/dist/packages/cli/src/commands/index.js +16 -0
- package/dist/packages/cli/src/commands/init.js +65 -0
- package/dist/packages/cli/src/commands/review.js +71 -0
- package/dist/packages/cli/src/commands/task.js +141 -0
- package/dist/packages/cli/src/commands/trust.js +48 -0
- package/dist/packages/cli/src/commands/uninstall.js +58 -0
- package/dist/packages/cli/src/commands/update.js +58 -0
- package/dist/packages/cli/src/commands/verify.js +108 -0
- package/dist/packages/cli/src/output.js +53 -0
- package/dist/packages/cli/src/plan-output.js +28 -0
- package/dist/packages/cli/src/wizard.js +75 -0
- package/dist/runtime/src/adapters/claude/config.js +109 -0
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/claude/input.js +38 -0
- package/dist/runtime/src/adapters/claude/output.js +30 -0
- package/dist/runtime/src/adapters/codex/config.js +93 -0
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/codex/input.js +41 -0
- package/dist/runtime/src/adapters/codex/output.js +24 -0
- package/dist/runtime/src/config/explain.js +34 -0
- package/dist/runtime/src/config/load.js +25 -0
- package/dist/runtime/src/config/merge.js +170 -0
- package/dist/runtime/src/config/migrate.js +61 -0
- package/dist/runtime/src/contracts.js +1 -0
- package/dist/runtime/src/discovery/go.js +145 -0
- package/dist/runtime/src/discovery/index.js +40 -0
- package/dist/runtime/src/discovery/make.js +162 -0
- package/dist/runtime/src/discovery/node.js +175 -0
- package/dist/runtime/src/discovery/python.js +163 -0
- package/dist/runtime/src/discovery/rust.js +159 -0
- package/dist/runtime/src/discovery/types.js +1 -0
- package/dist/runtime/src/fs/hash.js +19 -0
- package/dist/runtime/src/fs/managed-block.js +90 -0
- package/dist/runtime/src/fs/manifest.js +24 -0
- package/dist/runtime/src/fs/mutation-worker.js +185 -0
- package/dist/runtime/src/fs/paths.js +96 -0
- package/dist/runtime/src/fs/transaction.js +498 -0
- package/dist/runtime/src/guardrails/destructive.js +207 -0
- package/dist/runtime/src/guardrails/evaluate.js +9 -0
- package/dist/runtime/src/guardrails/exceptions.js +49 -0
- package/dist/runtime/src/guardrails/secrets.js +97 -0
- package/dist/runtime/src/guardrails/types.js +9 -0
- package/dist/runtime/src/hooks/dispatch.js +78 -0
- package/dist/runtime/src/hooks/events.js +1 -0
- package/dist/runtime/src/hooks/hook-entry.js +19 -0
- package/dist/runtime/src/hooks/normalize.js +59 -0
- package/dist/runtime/src/hooks/output.js +12 -0
- package/dist/runtime/src/hooks/shell.js +138 -0
- package/dist/runtime/src/hooks/stop-verify.js +70 -0
- package/dist/runtime/src/install/apply.js +70 -0
- package/dist/runtime/src/install/doctor.js +196 -0
- package/dist/runtime/src/install/harness.js +87 -0
- package/dist/runtime/src/install/ownership.js +84 -0
- package/dist/runtime/src/install/plan.js +257 -0
- package/dist/runtime/src/install/profiles.js +28 -0
- package/dist/runtime/src/install/types.js +1 -0
- package/dist/runtime/src/install/uninstall.js +206 -0
- package/dist/runtime/src/install/update.js +123 -0
- package/dist/runtime/src/logging/local-log.js +158 -0
- package/dist/runtime/src/registry/npm.js +141 -0
- package/dist/runtime/src/review/claude-runner.js +4 -0
- package/dist/runtime/src/review/codex-runner.js +4 -0
- package/dist/runtime/src/review/packet.js +10 -0
- package/dist/runtime/src/review/result.js +24 -0
- package/dist/runtime/src/review/roles.js +3 -0
- package/dist/runtime/src/review/runner.js +45 -0
- package/dist/runtime/src/schema/validate.js +584 -0
- package/dist/runtime/src/security/permissions.js +654 -0
- package/dist/runtime/src/security/redact.js +41 -0
- package/dist/runtime/src/security/trust.js +209 -0
- package/dist/runtime/src/task/render.js +43 -0
- package/dist/runtime/src/task/service.js +235 -0
- package/dist/runtime/src/task/store.js +265 -0
- package/dist/runtime/src/verify/change-surface.js +86 -0
- package/dist/runtime/src/verify/evidence.js +89 -0
- package/dist/runtime/src/verify/fingerprint.js +67 -0
- package/dist/runtime/src/verify/scope.js +69 -0
- package/dist/runtime/src/verify/service.js +217 -0
- package/dist/runtime/src/verify/spawn.js +326 -0
- package/dist/runtime/src/verify/test-count.js +148 -0
- package/docs/en/spec/README.md +13 -0
- package/docs/en/spec/acceptance-and-evidence.md +21 -0
- package/docs/en/spec/delegation.md +21 -0
- package/docs/en/spec/guardrails.md +21 -0
- package/docs/en/spec/harness-adapters.md +21 -0
- package/docs/en/spec/judgment.md +21 -0
- package/docs/en/spec/loop-engineering.md +23 -0
- package/docs/en/spec/maintenance.md +21 -0
- package/docs/en/spec/review.md +21 -0
- package/docs/en/spec/troubleshooting.md +21 -0
- package/docs/zh-TW/spec/README.md +13 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +23 -0
- package/docs/zh-TW/spec/delegation.md +23 -0
- package/docs/zh-TW/spec/guardrails.md +23 -0
- package/docs/zh-TW/spec/harness-adapters.md +23 -0
- package/docs/zh-TW/spec/judgment.md +23 -0
- package/docs/zh-TW/spec/loop-engineering.md +23 -0
- package/docs/zh-TW/spec/maintenance.md +23 -0
- package/docs/zh-TW/spec/review.md +23 -0
- package/docs/zh-TW/spec/troubleshooting.md +23 -0
- package/package.json +41 -0
- package/schemas/config.schema.json +231 -0
- package/schemas/evidence.schema.json +115 -0
- package/schemas/manifest.schema.json +116 -0
- package/schemas/task.schema.json +59 -0
- package/templates/common/AGENTS.block.md +3 -0
- package/templates/common/CLAUDE.block.md +3 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
2
|
+
import { validateTaskAgainstConfig } from "../schema/validate.js";
|
|
3
|
+
import { collectChangeSurface } from "./change-surface.js";
|
|
4
|
+
import { buildVerificationEvidence } from "./evidence.js";
|
|
5
|
+
import { createFailureFingerprint } from "./fingerprint.js";
|
|
6
|
+
import { selectVerificationScope } from "./scope.js";
|
|
7
|
+
import { runVerificationCommand } from "./spawn.js";
|
|
8
|
+
import { evaluateTestCount, parseTestCount } from "./test-count.js";
|
|
9
|
+
function verificationError(code, message) {
|
|
10
|
+
return new AgentOpsError(code, message);
|
|
11
|
+
}
|
|
12
|
+
function classifyTestCountCode(code) {
|
|
13
|
+
const classes = {
|
|
14
|
+
TEST_COUNT_BELOW_MINIMUM: "test-count-below-minimum",
|
|
15
|
+
TEST_COUNT_INVALID: "test-count-invalid",
|
|
16
|
+
TEST_COUNT_OK: "none",
|
|
17
|
+
TEST_COUNT_REQUIREMENT_INVALID: "test-count-requirement-invalid",
|
|
18
|
+
TEST_COUNT_UNPARSEABLE: "test-count-unparseable",
|
|
19
|
+
ZERO_TESTS: "zero-tests"
|
|
20
|
+
};
|
|
21
|
+
return classes[code];
|
|
22
|
+
}
|
|
23
|
+
function classifyResult(command, spawned) {
|
|
24
|
+
if (command.evidence.kind === "file") {
|
|
25
|
+
return {
|
|
26
|
+
status: spawned.status === "PASS" ? "UNKNOWN" : spawned.status,
|
|
27
|
+
failureClass: spawned.status === "PASS"
|
|
28
|
+
? "file-evidence-unsupported"
|
|
29
|
+
: spawned.failureClass,
|
|
30
|
+
testCount: null
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (command.evidence.kind !== "test-count") {
|
|
34
|
+
return {
|
|
35
|
+
status: spawned.status,
|
|
36
|
+
failureClass: spawned.failureClass,
|
|
37
|
+
testCount: null
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const testCount = parseTestCount(`${spawned.stdout}\n${spawned.stderr}`);
|
|
41
|
+
if (spawned.status !== "PASS") {
|
|
42
|
+
return {
|
|
43
|
+
status: spawned.status,
|
|
44
|
+
failureClass: spawned.failureClass,
|
|
45
|
+
testCount
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const evaluation = evaluateTestCount(testCount, command.evidence.minimum);
|
|
49
|
+
return {
|
|
50
|
+
status: evaluation.status,
|
|
51
|
+
failureClass: classifyTestCountCode(evaluation.code),
|
|
52
|
+
testCount: evaluation.testCount
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function exitCategory(result) {
|
|
56
|
+
if (result.timedOut) {
|
|
57
|
+
return "timeout";
|
|
58
|
+
}
|
|
59
|
+
if (result.failureClass === "signal-exit") {
|
|
60
|
+
return "signal-exit";
|
|
61
|
+
}
|
|
62
|
+
if (result.exitCode === null) {
|
|
63
|
+
return "no-exit";
|
|
64
|
+
}
|
|
65
|
+
return result.exitCode === 0 ? "exit-zero" : "nonzero-exit";
|
|
66
|
+
}
|
|
67
|
+
function overallStatus(results) {
|
|
68
|
+
const required = results.filter((result) => result.required);
|
|
69
|
+
const gating = required.length > 0 ? required : results;
|
|
70
|
+
if (gating.some((result) => result.status === "FAIL")) {
|
|
71
|
+
return "FAIL";
|
|
72
|
+
}
|
|
73
|
+
if (gating.some((result) => result.status === "UNKNOWN")) {
|
|
74
|
+
return "UNKNOWN";
|
|
75
|
+
}
|
|
76
|
+
return "PASS";
|
|
77
|
+
}
|
|
78
|
+
function relevantCriteria(task, commandId) {
|
|
79
|
+
return task.criteria.filter((criterion) => criterion.verifierIds.includes(commandId));
|
|
80
|
+
}
|
|
81
|
+
function commandById(config, commandId) {
|
|
82
|
+
const command = config.verification.commands.find((candidate) => candidate.id === commandId);
|
|
83
|
+
if (command === undefined) {
|
|
84
|
+
throw verificationError("VERIFICATION_COMMAND_NOT_FOUND", `Verification command not found: ${commandId}`);
|
|
85
|
+
}
|
|
86
|
+
return command;
|
|
87
|
+
}
|
|
88
|
+
function diagnostics(spawned) {
|
|
89
|
+
return spawned.stderr || spawned.stdout || spawned.failureClass;
|
|
90
|
+
}
|
|
91
|
+
export class VerificationService {
|
|
92
|
+
#options;
|
|
93
|
+
constructor(options) {
|
|
94
|
+
this.#options = options;
|
|
95
|
+
}
|
|
96
|
+
async #commandCwd(command) {
|
|
97
|
+
if (command.cwd === ".") {
|
|
98
|
+
return this.#options.root;
|
|
99
|
+
}
|
|
100
|
+
return await resolveContainedPath(this.#options.root, command.cwd);
|
|
101
|
+
}
|
|
102
|
+
async #persistEvidence(task, command, startedAt, finishedAt, result, exitCode) {
|
|
103
|
+
const references = [];
|
|
104
|
+
for (const criterion of relevantCriteria(task, command.id)) {
|
|
105
|
+
const evidence = buildVerificationEvidence({
|
|
106
|
+
taskId: task.id,
|
|
107
|
+
criterionId: criterion.id,
|
|
108
|
+
command,
|
|
109
|
+
scope: this.#options.scope,
|
|
110
|
+
startedAt,
|
|
111
|
+
finishedAt,
|
|
112
|
+
exitCode,
|
|
113
|
+
testCount: result.testCount,
|
|
114
|
+
toolVersions: this.#options.toolVersions ?? {},
|
|
115
|
+
config: this.#options.config
|
|
116
|
+
});
|
|
117
|
+
references.push(await this.#options.evidenceStore.save(evidence));
|
|
118
|
+
}
|
|
119
|
+
return references;
|
|
120
|
+
}
|
|
121
|
+
async #runCommand(task, command) {
|
|
122
|
+
const startedAt = (this.#options.now ?? (() => new Date().toISOString()))();
|
|
123
|
+
const spawned = this.#options.trusted
|
|
124
|
+
? await runVerificationCommand(command, {
|
|
125
|
+
cwd: await this.#commandCwd(command),
|
|
126
|
+
runner: this.#options.processRunner
|
|
127
|
+
})
|
|
128
|
+
: {
|
|
129
|
+
commandId: command.id,
|
|
130
|
+
status: "UNKNOWN",
|
|
131
|
+
failureClass: "repository-untrusted",
|
|
132
|
+
exitCode: null,
|
|
133
|
+
signal: null,
|
|
134
|
+
timedOut: false,
|
|
135
|
+
durationMs: 0,
|
|
136
|
+
stdout: "",
|
|
137
|
+
stderr: "",
|
|
138
|
+
stdoutTruncated: false,
|
|
139
|
+
stderrTruncated: false
|
|
140
|
+
};
|
|
141
|
+
const classified = classifyResult(command, spawned);
|
|
142
|
+
const fingerprint = classified.status === "PASS"
|
|
143
|
+
? null
|
|
144
|
+
: createFailureFingerprint({
|
|
145
|
+
commandId: command.id,
|
|
146
|
+
failureClass: classified.failureClass,
|
|
147
|
+
exitCategory: spawned.timedOut
|
|
148
|
+
? "timeout"
|
|
149
|
+
: spawned.signal !== null
|
|
150
|
+
? "signal-exit"
|
|
151
|
+
: spawned.exitCode === null
|
|
152
|
+
? "no-exit"
|
|
153
|
+
: spawned.exitCode === 0
|
|
154
|
+
? "exit-zero"
|
|
155
|
+
: "nonzero-exit",
|
|
156
|
+
diagnostics: diagnostics(spawned)
|
|
157
|
+
});
|
|
158
|
+
const finishedAt = (this.#options.now ?? (() => new Date().toISOString()))();
|
|
159
|
+
const evidenceReferences = await this.#persistEvidence(task, command, startedAt, finishedAt, classified, spawned.exitCode);
|
|
160
|
+
return {
|
|
161
|
+
commandId: command.id,
|
|
162
|
+
required: command.required,
|
|
163
|
+
status: classified.status,
|
|
164
|
+
failureClass: classified.failureClass,
|
|
165
|
+
exitCode: spawned.exitCode,
|
|
166
|
+
timedOut: spawned.timedOut,
|
|
167
|
+
testCount: classified.testCount,
|
|
168
|
+
diagnostic: fingerprint?.diagnostics ?? "",
|
|
169
|
+
evidenceReferences
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async verify(taskId) {
|
|
173
|
+
const stored = await this.#options.taskService.status({ taskId });
|
|
174
|
+
if (stored.status === "archived") {
|
|
175
|
+
throw verificationError("TASK_NOT_ACTIVE", "An archived task cannot be verified.");
|
|
176
|
+
}
|
|
177
|
+
const validation = validateTaskAgainstConfig(stored.task, this.#options.config);
|
|
178
|
+
if (!validation.ok) {
|
|
179
|
+
throw verificationError("VERIFICATION_INPUT_INVALID", validation.errors[0]?.message ??
|
|
180
|
+
"Task and verification configuration are incompatible.");
|
|
181
|
+
}
|
|
182
|
+
const surface = await collectChangeSurface(this.#options.gitRunner);
|
|
183
|
+
const selection = selectVerificationScope(surface.paths, this.#options.config);
|
|
184
|
+
const results = [];
|
|
185
|
+
for (const commandId of selection.verifierIds) {
|
|
186
|
+
results.push(await this.#runCommand(validation.value, commandById(this.#options.config, commandId)));
|
|
187
|
+
}
|
|
188
|
+
const status = overallStatus(results);
|
|
189
|
+
let signal = null;
|
|
190
|
+
if (status === "PASS") {
|
|
191
|
+
await this.#options.taskService.clearFailure(taskId);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const required = results.filter((result) => result.required);
|
|
195
|
+
const gating = required.length > 0 ? required : results;
|
|
196
|
+
const failed = gating.find((result) => result.status !== "PASS");
|
|
197
|
+
if (failed === undefined) {
|
|
198
|
+
throw verificationError("VERIFICATION_RESULT_INVALID", "Verification failed without a gating result.");
|
|
199
|
+
}
|
|
200
|
+
const advanced = await this.#options.taskService.recordFailure(taskId, createFailureFingerprint({
|
|
201
|
+
commandId: failed.commandId,
|
|
202
|
+
failureClass: failed.failureClass,
|
|
203
|
+
exitCategory: exitCategory(failed),
|
|
204
|
+
diagnostics: failed.diagnostic
|
|
205
|
+
}));
|
|
206
|
+
signal = advanced.signal;
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
taskId,
|
|
210
|
+
status,
|
|
211
|
+
surface,
|
|
212
|
+
selection,
|
|
213
|
+
results,
|
|
214
|
+
signal
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { execFile as execFileCallback, spawn } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
4
|
+
const DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024;
|
|
5
|
+
const MAX_OUTPUT_LIMIT_BYTES = 1024 * 1024;
|
|
6
|
+
const DEFAULT_TERMINATION_GRACE_MS = 500;
|
|
7
|
+
const MAX_TERMINATION_GRACE_MS = 5_000;
|
|
8
|
+
const execFile = promisify(execFileCallback);
|
|
9
|
+
function boundedPositiveInteger(value, maximum, name) {
|
|
10
|
+
if (!Number.isSafeInteger(value) ||
|
|
11
|
+
value <= 0 ||
|
|
12
|
+
value > maximum) {
|
|
13
|
+
throw new RangeError(`${name} must be a positive integer no greater than ${maximum}.`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function errorCode(error) {
|
|
18
|
+
if (typeof error === "object" &&
|
|
19
|
+
error !== null &&
|
|
20
|
+
"code" in error &&
|
|
21
|
+
typeof error.code === "string") {
|
|
22
|
+
return error.code;
|
|
23
|
+
}
|
|
24
|
+
return "UNKNOWN";
|
|
25
|
+
}
|
|
26
|
+
async function* readableBytes(stream) {
|
|
27
|
+
if (stream === null) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for await (const value of stream) {
|
|
31
|
+
if (typeof value === "string") {
|
|
32
|
+
yield Buffer.from(value);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (value instanceof Uint8Array) {
|
|
36
|
+
yield value;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
throw new TypeError("Process output contained an unsupported chunk.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function captureOutput(stream, limit) {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let storedBytes = 0;
|
|
45
|
+
let truncated = false;
|
|
46
|
+
try {
|
|
47
|
+
for await (const value of stream) {
|
|
48
|
+
const chunk = Buffer.from(value);
|
|
49
|
+
const remaining = limit - storedBytes;
|
|
50
|
+
if (remaining > 0) {
|
|
51
|
+
const retained = chunk.subarray(0, remaining);
|
|
52
|
+
chunks.push(retained);
|
|
53
|
+
storedBytes += retained.length;
|
|
54
|
+
}
|
|
55
|
+
if (chunk.length > remaining) {
|
|
56
|
+
truncated = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return {
|
|
62
|
+
text: Buffer.concat(chunks, storedBytes).toString("utf8"),
|
|
63
|
+
truncated,
|
|
64
|
+
failed: true
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
text: Buffer.concat(chunks, storedBytes).toString("utf8"),
|
|
69
|
+
truncated,
|
|
70
|
+
failed: false
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function settleCapturedOutput(capture, graceMs) {
|
|
74
|
+
let timer;
|
|
75
|
+
const timeout = new Promise((resolve) => {
|
|
76
|
+
timer = setTimeout(() => resolve({
|
|
77
|
+
text: "",
|
|
78
|
+
truncated: true,
|
|
79
|
+
failed: true
|
|
80
|
+
}), graceMs);
|
|
81
|
+
});
|
|
82
|
+
return Promise.race([capture, timeout]).finally(() => {
|
|
83
|
+
if (timer !== undefined) {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function isMissingProcess(errorCodeValue) {
|
|
89
|
+
return errorCodeValue === "ENOENT";
|
|
90
|
+
}
|
|
91
|
+
function waitForCompletion(completion, timeoutMs) {
|
|
92
|
+
let timer;
|
|
93
|
+
const timeout = new Promise((resolve) => {
|
|
94
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
95
|
+
});
|
|
96
|
+
return Promise.race([completion, timeout]).finally(() => {
|
|
97
|
+
if (timer !== undefined) {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async function terminatePosixTree(processId, completion, graceMs) {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(-processId, "SIGTERM");
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (errorCode(error) === "ESRCH") {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
await waitForCompletion(completion, graceMs);
|
|
113
|
+
try {
|
|
114
|
+
process.kill(-processId, "SIGKILL");
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (errorCode(error) !== "ESRCH") {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function defaultTerminateWindowsTree(processId, graceMs) {
|
|
123
|
+
await execFile("taskkill", ["/pid", String(processId), "/T", "/F"], {
|
|
124
|
+
encoding: "utf8",
|
|
125
|
+
timeout: graceMs,
|
|
126
|
+
windowsHide: true
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
export class NodeVerificationProcessRunner {
|
|
130
|
+
#platform;
|
|
131
|
+
#terminateWindowsTree;
|
|
132
|
+
constructor(options = {}) {
|
|
133
|
+
this.#platform = options.platform ?? process.platform;
|
|
134
|
+
this.#terminateWindowsTree =
|
|
135
|
+
options.terminateWindowsTree ?? defaultTerminateWindowsTree;
|
|
136
|
+
}
|
|
137
|
+
start(request) {
|
|
138
|
+
const child = spawn(request.command, [...request.args], {
|
|
139
|
+
cwd: request.cwd,
|
|
140
|
+
detached: this.#platform !== "win32",
|
|
141
|
+
shell: request.shell,
|
|
142
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
143
|
+
windowsHide: true
|
|
144
|
+
});
|
|
145
|
+
let settled = false;
|
|
146
|
+
const completion = new Promise((resolve) => {
|
|
147
|
+
child.once("error", (error) => {
|
|
148
|
+
if (!settled) {
|
|
149
|
+
settled = true;
|
|
150
|
+
resolve({
|
|
151
|
+
exitCode: null,
|
|
152
|
+
signal: null,
|
|
153
|
+
errorCode: error.code ?? "UNKNOWN"
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
child.once("close", (exitCode, signal) => {
|
|
158
|
+
if (!settled) {
|
|
159
|
+
settled = true;
|
|
160
|
+
resolve({
|
|
161
|
+
exitCode,
|
|
162
|
+
signal
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
const processId = child.pid ?? null;
|
|
168
|
+
return {
|
|
169
|
+
pid: processId,
|
|
170
|
+
stdout: readableBytes(child.stdout),
|
|
171
|
+
stderr: readableBytes(child.stderr),
|
|
172
|
+
completion,
|
|
173
|
+
terminateTree: async (graceMs) => {
|
|
174
|
+
if (processId === null) {
|
|
175
|
+
throw new Error("Cannot terminate a process without an ID.");
|
|
176
|
+
}
|
|
177
|
+
if (this.#platform === "win32") {
|
|
178
|
+
await this.#terminateWindowsTree(processId, graceMs);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await terminatePosixTree(processId, completion, graceMs);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function emptyResult(commandId, failureClass, durationMs) {
|
|
187
|
+
return {
|
|
188
|
+
commandId,
|
|
189
|
+
status: "UNKNOWN",
|
|
190
|
+
failureClass,
|
|
191
|
+
exitCode: null,
|
|
192
|
+
signal: null,
|
|
193
|
+
timedOut: false,
|
|
194
|
+
durationMs,
|
|
195
|
+
stdout: "",
|
|
196
|
+
stderr: "",
|
|
197
|
+
stdoutTruncated: false,
|
|
198
|
+
stderrTruncated: false
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function classifyCompletion(completion, outputFailed) {
|
|
202
|
+
if (outputFailed) {
|
|
203
|
+
return {
|
|
204
|
+
status: "UNKNOWN",
|
|
205
|
+
failureClass: "output-read-failed"
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (isMissingProcess(completion.errorCode)) {
|
|
209
|
+
return {
|
|
210
|
+
status: "UNKNOWN",
|
|
211
|
+
failureClass: "missing-executable"
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (completion.errorCode !== undefined) {
|
|
215
|
+
return {
|
|
216
|
+
status: "UNKNOWN",
|
|
217
|
+
failureClass: "spawn-failed"
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (completion.signal !== null) {
|
|
221
|
+
return {
|
|
222
|
+
status: "FAIL",
|
|
223
|
+
failureClass: "signal-exit"
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (completion.exitCode === 0) {
|
|
227
|
+
return {
|
|
228
|
+
status: "PASS",
|
|
229
|
+
failureClass: "none"
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
status: "FAIL",
|
|
234
|
+
failureClass: "nonzero-exit"
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function elapsedMilliseconds(startedAt, finishedAt) {
|
|
238
|
+
const elapsed = Math.max(0, finishedAt - startedAt);
|
|
239
|
+
return Number.isFinite(elapsed) ? Math.round(elapsed) : 0;
|
|
240
|
+
}
|
|
241
|
+
function hasAcknowledgedShell(command) {
|
|
242
|
+
const value = command;
|
|
243
|
+
return value.shell !== true || value.acknowledgeRisk === true;
|
|
244
|
+
}
|
|
245
|
+
export async function runVerificationCommand(command, options) {
|
|
246
|
+
const now = options.now ?? Date.now;
|
|
247
|
+
const startedAt = now();
|
|
248
|
+
if (!hasAcknowledgedShell(command)) {
|
|
249
|
+
return emptyResult(command.id, "shell-risk-unacknowledged", elapsedMilliseconds(startedAt, now()));
|
|
250
|
+
}
|
|
251
|
+
const outputLimit = boundedPositiveInteger(options.outputLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, MAX_OUTPUT_LIMIT_BYTES, "outputLimitBytes");
|
|
252
|
+
const terminationGrace = boundedPositiveInteger(options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS, MAX_TERMINATION_GRACE_MS, "terminationGraceMs");
|
|
253
|
+
const timeoutMs = boundedPositiveInteger(command.timeoutMs ?? DEFAULT_TIMEOUT_MS, 2_147_483_647, "timeoutMs");
|
|
254
|
+
const runner = options.runner ?? new NodeVerificationProcessRunner();
|
|
255
|
+
let running;
|
|
256
|
+
try {
|
|
257
|
+
running = runner.start({
|
|
258
|
+
command: command.command,
|
|
259
|
+
args: [...command.args],
|
|
260
|
+
cwd: options.cwd,
|
|
261
|
+
shell: command.shell === true
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return emptyResult(command.id, "spawn-failed", elapsedMilliseconds(startedAt, now()));
|
|
266
|
+
}
|
|
267
|
+
const stdout = captureOutput(running.stdout, outputLimit);
|
|
268
|
+
const stderr = captureOutput(running.stderr, outputLimit);
|
|
269
|
+
let timer;
|
|
270
|
+
const timeout = new Promise((resolve) => {
|
|
271
|
+
timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
|
|
272
|
+
});
|
|
273
|
+
const outcome = await Promise.race([
|
|
274
|
+
running.completion.then((completion) => ({
|
|
275
|
+
kind: "completion",
|
|
276
|
+
completion
|
|
277
|
+
})),
|
|
278
|
+
timeout
|
|
279
|
+
]);
|
|
280
|
+
if (timer !== undefined) {
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
}
|
|
283
|
+
let completion;
|
|
284
|
+
let timedOut = false;
|
|
285
|
+
let terminationFailed = false;
|
|
286
|
+
if (outcome.kind === "timeout") {
|
|
287
|
+
timedOut = true;
|
|
288
|
+
try {
|
|
289
|
+
await running.terminateTree(terminationGrace);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
terminationFailed = true;
|
|
293
|
+
}
|
|
294
|
+
completion =
|
|
295
|
+
await waitForCompletion(running.completion, terminationGrace) ??
|
|
296
|
+
{ exitCode: null, signal: null };
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
completion = outcome.completion;
|
|
300
|
+
}
|
|
301
|
+
const [capturedStdout, capturedStderr] = await Promise.all([
|
|
302
|
+
settleCapturedOutput(stdout, terminationGrace),
|
|
303
|
+
settleCapturedOutput(stderr, terminationGrace)
|
|
304
|
+
]);
|
|
305
|
+
const classified = timedOut
|
|
306
|
+
? {
|
|
307
|
+
status: terminationFailed ? "UNKNOWN" : "FAIL",
|
|
308
|
+
failureClass: terminationFailed
|
|
309
|
+
? "termination-failed"
|
|
310
|
+
: "timeout"
|
|
311
|
+
}
|
|
312
|
+
: classifyCompletion(completion, capturedStdout.failed || capturedStderr.failed);
|
|
313
|
+
return {
|
|
314
|
+
commandId: command.id,
|
|
315
|
+
status: classified.status,
|
|
316
|
+
failureClass: classified.failureClass,
|
|
317
|
+
exitCode: completion.exitCode,
|
|
318
|
+
signal: completion.signal,
|
|
319
|
+
timedOut,
|
|
320
|
+
durationMs: elapsedMilliseconds(startedAt, now()),
|
|
321
|
+
stdout: capturedStdout.text,
|
|
322
|
+
stderr: capturedStderr.text,
|
|
323
|
+
stdoutTruncated: capturedStdout.truncated,
|
|
324
|
+
stderrTruncated: capturedStderr.truncated
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const MAX_SUMMARY_BYTES = 1024 * 1024;
|
|
2
|
+
const NUMBER_SOURCE = String.raw `\d{1,16}`;
|
|
3
|
+
function safeCount(value) {
|
|
4
|
+
const parsed = Number(value);
|
|
5
|
+
return Number.isSafeInteger(parsed) && parsed >= 0
|
|
6
|
+
? parsed
|
|
7
|
+
: null;
|
|
8
|
+
}
|
|
9
|
+
function addCandidate(candidates, source) {
|
|
10
|
+
const parsed = safeCount(source);
|
|
11
|
+
if (parsed !== null) {
|
|
12
|
+
candidates.push(parsed);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function parsePytestSummary(line) {
|
|
16
|
+
if (!/\bin \d+(?:\.\d+)?s(?:\s|$)/u.test(line)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const statusPattern = new RegExp(`(${NUMBER_SOURCE}) (passed|failed|skipped|error|errors|xfailed|xpassed|deselected)`, "gu");
|
|
20
|
+
let total = 0;
|
|
21
|
+
let matched = false;
|
|
22
|
+
for (const match of line.matchAll(statusPattern)) {
|
|
23
|
+
const source = match[1];
|
|
24
|
+
const status = match[2];
|
|
25
|
+
if (source === undefined || status === undefined) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const count = safeCount(source);
|
|
29
|
+
if (count === null ||
|
|
30
|
+
(status !== "deselected" &&
|
|
31
|
+
total > Number.MAX_SAFE_INTEGER - count)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (status !== "deselected") {
|
|
35
|
+
total += count;
|
|
36
|
+
}
|
|
37
|
+
matched = true;
|
|
38
|
+
}
|
|
39
|
+
return matched ? total : null;
|
|
40
|
+
}
|
|
41
|
+
function parseRustSummary(line) {
|
|
42
|
+
const match = new RegExp(`^test result: (?:ok|FAILED)\\. (${NUMBER_SOURCE}) passed; (${NUMBER_SOURCE}) failed; ${NUMBER_SOURCE} ignored; ${NUMBER_SOURCE} measured; ${NUMBER_SOURCE} filtered out$`, "u").exec(line);
|
|
43
|
+
if (match === null) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const passedSource = match[1];
|
|
47
|
+
const failedSource = match[2];
|
|
48
|
+
if (passedSource === undefined || failedSource === undefined) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const passed = safeCount(passedSource);
|
|
52
|
+
const failed = safeCount(failedSource);
|
|
53
|
+
if (passed === null ||
|
|
54
|
+
failed === null ||
|
|
55
|
+
passed > Number.MAX_SAFE_INTEGER - failed) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return passed + failed;
|
|
59
|
+
}
|
|
60
|
+
export function parseTestCount(output) {
|
|
61
|
+
if (output.includes("\0") ||
|
|
62
|
+
Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const candidates = [];
|
|
66
|
+
const nodePattern = new RegExp(`^# tests (${NUMBER_SOURCE})$`, "u");
|
|
67
|
+
const collectedPattern = new RegExp(`^collected (${NUMBER_SOURCE}) items?$`, "u");
|
|
68
|
+
const jestPattern = new RegExp(`^Tests:\\s+.*\\b(${NUMBER_SOURCE}) total(?:\\s|$)`, "u");
|
|
69
|
+
const vitestPattern = new RegExp(`^Tests\\s+.*\\((${NUMBER_SOURCE})\\)\\s*$`, "u");
|
|
70
|
+
for (const rawLine of output.split(/\r?\n/u)) {
|
|
71
|
+
const line = rawLine.trim();
|
|
72
|
+
if (line.length === 0) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const node = nodePattern.exec(line);
|
|
76
|
+
if (node?.[1] !== undefined) {
|
|
77
|
+
addCandidate(candidates, node[1]);
|
|
78
|
+
}
|
|
79
|
+
const collected = collectedPattern.exec(line);
|
|
80
|
+
if (collected?.[1] !== undefined) {
|
|
81
|
+
addCandidate(candidates, collected[1]);
|
|
82
|
+
}
|
|
83
|
+
const jest = jestPattern.exec(line);
|
|
84
|
+
if (jest?.[1] !== undefined) {
|
|
85
|
+
addCandidate(candidates, jest[1]);
|
|
86
|
+
}
|
|
87
|
+
const vitest = vitestPattern.exec(line);
|
|
88
|
+
if (vitest?.[1] !== undefined) {
|
|
89
|
+
addCandidate(candidates, vitest[1]);
|
|
90
|
+
}
|
|
91
|
+
const pytest = parsePytestSummary(line);
|
|
92
|
+
if (pytest !== null) {
|
|
93
|
+
candidates.push(pytest);
|
|
94
|
+
}
|
|
95
|
+
const rust = parseRustSummary(line);
|
|
96
|
+
if (rust !== null) {
|
|
97
|
+
candidates.push(rust);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const first = candidates[0];
|
|
101
|
+
if (first === undefined ||
|
|
102
|
+
candidates.some((candidate) => candidate !== first)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return first;
|
|
106
|
+
}
|
|
107
|
+
export function evaluateTestCount(testCount, minimum = 1) {
|
|
108
|
+
if (!Number.isSafeInteger(minimum) || minimum < 0) {
|
|
109
|
+
return {
|
|
110
|
+
status: "UNKNOWN",
|
|
111
|
+
code: "TEST_COUNT_REQUIREMENT_INVALID",
|
|
112
|
+
testCount
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (testCount === null) {
|
|
116
|
+
return {
|
|
117
|
+
status: "UNKNOWN",
|
|
118
|
+
code: "TEST_COUNT_UNPARSEABLE",
|
|
119
|
+
testCount: null
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (!Number.isSafeInteger(testCount) || testCount < 0) {
|
|
123
|
+
return {
|
|
124
|
+
status: "UNKNOWN",
|
|
125
|
+
code: "TEST_COUNT_INVALID",
|
|
126
|
+
testCount: null
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (testCount === 0) {
|
|
130
|
+
return {
|
|
131
|
+
status: "FAIL",
|
|
132
|
+
code: "ZERO_TESTS",
|
|
133
|
+
testCount
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (testCount < minimum) {
|
|
137
|
+
return {
|
|
138
|
+
status: "FAIL",
|
|
139
|
+
code: "TEST_COUNT_BELOW_MINIMUM",
|
|
140
|
+
testCount
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
status: "PASS",
|
|
145
|
+
code: "TEST_COUNT_OK",
|
|
146
|
+
testCount
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Loop Engineering Specification
|
|
2
|
+
|
|
3
|
+
This is the normative English specification for bounded, evidence-driven work.
|
|
4
|
+
|
|
5
|
+
- [Loop engineering](./loop-engineering.md)
|
|
6
|
+
- [Acceptance and evidence](./acceptance-and-evidence.md)
|
|
7
|
+
- [Judgment](./judgment.md)
|
|
8
|
+
- [Delegation](./delegation.md)
|
|
9
|
+
- [Review](./review.md)
|
|
10
|
+
- [Troubleshooting](./troubleshooting.md)
|
|
11
|
+
- [Guardrails](./guardrails.md)
|
|
12
|
+
- [Maintenance](./maintenance.md)
|
|
13
|
+
- [Harness adapters](./harness-adapters.md)
|