@agentsfleet/orly 0.10.14 → 0.12.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/.githooks/pre-push +2 -1
- package/AGENTS.md +21 -31
- package/README.md +94 -28
- package/SOUL.md +13 -23
- package/audits/agents-md.md +67 -57
- package/audits/deinit-pairs.sh +13 -18
- package/audits/design-tokens.sh +9 -23
- package/audits/doc-read.sh +10 -33
- package/audits/doc-reads.ts +41 -0
- package/audits/error-codes.sh +18 -25
- package/audits/logging-rust.awk +145 -0
- package/audits/logging.sh +10 -166
- package/audits/msid-ui.sh +45 -18
- package/audits/rule-ledger-lib.sh +9 -6
- package/audits/rust-error.sh +20 -15
- package/audits/scope.sh +88 -0
- package/audits/spec-template.sh +2 -2
- package/audits/spec-template.ts +1 -1
- package/audits/sql-mod.sh +11 -14
- package/audits/ufs-strings.awk +164 -0
- package/audits/ufs.sh +10 -188
- package/core/operating-model.md +8 -8
- package/dispatch/edit_rules.md +8 -6
- package/dispatch/lib.sh +41 -26
- package/dispatch/lifecycle.md +12 -5
- package/dispatch/name_architecture.md +8 -3
- package/dispatch/verify.md +1 -1
- package/dispatch/write_any.md +10 -3
- package/dispatch/write_changelog.md +7 -4
- package/dispatch/write_go.md +1 -1
- package/dispatch/write_http.md +5 -7
- package/dispatch/write_python.md +1 -1
- package/dispatch/write_rust.md +17 -1
- package/dispatch/write_shell.md +1 -1
- package/dispatch/write_spec.md +3 -1
- package/dispatch/write_sql.md +28 -6
- package/dispatch/write_ts_adhere_bun.md +11 -0
- package/dispatch/write_zig.md +53 -21
- package/docs/CHANGELOG.md +10 -0
- package/docs/CHANGELOG_VOICE.md +11 -2
- package/docs/EXECUTE_DOC_READS.md +10 -10
- package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
- package/docs/JUDGMENTS.md +156 -0
- package/docs/LIFECYCLE_PATTERNS.md +26 -16
- package/docs/LOGGING_STANDARD.md +39 -19
- package/docs/ORLY_ARCHITECTURE.md +77 -6
- package/docs/RELEASE_TEMPLATE.md +21 -5
- package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
- package/docs/RULE_ENFORCEMENT.md +6 -6
- package/docs/SCHEMA_CONVENTIONS.md +24 -3
- package/docs/TEMPLATE.md +4 -4
- package/docs/VERIFY_TIERS.md +2 -2
- package/docs/greptile-learnings/RULES.md +81 -26
- package/llms.txt +46 -34
- package/package.json +6 -1
- package/packs/language/go/rules.md +1 -1
- package/packs/language/python/rules.md +1 -1
- package/packs/language/rust/rules.md +17 -1
- package/packs/language/shell/rules.md +1 -1
- package/registry.json +22 -7
- package/schemas/profile.schema.json +48 -5
- package/skills/orly-babysit-prs/SKILL.md +87 -34
- package/skills/orly-spec-new/SKILL.md +19 -6
- package/skills/orly-write-integration-test/SKILL.md +4 -4
- package/skills/orly-write-unit-test/SKILL.md +10 -1
- package/src/cli.test.ts +2 -2
- package/src/cli.ts +19 -75
- package/src/cli_help.ts +69 -0
- package/src/command_limits.ts +20 -0
- package/src/command_process.test.ts +109 -0
- package/src/command_process.ts +88 -0
- package/src/command_runner.test.ts +88 -0
- package/src/command_runner.ts +33 -0
- package/src/comprehension_cache.test.ts +123 -0
- package/src/config.test.ts +5 -4
- package/src/config.ts +26 -164
- package/src/config_discovery.ts +159 -0
- package/src/config_validation.test.ts +56 -0
- package/src/config_validation.ts +25 -0
- package/src/criteria.test.ts +1 -1
- package/src/criteria.ts +10 -26
- package/src/criteria_spec.ts +12 -20
- package/src/criteria_support.ts +2 -19
- package/src/doc_reads.test.ts +42 -0
- package/src/doc_rules.ts +2 -2
- package/src/evaluation_resources.test.ts +64 -0
- package/src/execution/config.ts +22 -0
- package/src/execution/plan.test.ts +65 -0
- package/src/execution/plan.ts +51 -0
- package/src/gates.ts +9 -6
- package/src/gates_spec.test.ts +52 -6
- package/src/gates_test_support.ts +9 -7
- package/src/install.test.ts +38 -222
- package/src/install.ts +117 -308
- package/src/install_loaders.test.ts +203 -0
- package/src/install_nested.test.ts +2 -2
- package/src/install_packs.test.ts +66 -6
- package/src/installation/hooks.ts +63 -0
- package/src/installation/lock.test.ts +97 -0
- package/src/installation/lock.ts +56 -0
- package/src/installation/migration.test.ts +212 -0
- package/src/installation/migration.ts +65 -0
- package/src/installation/plan.ts +96 -0
- package/src/installation/transaction.ts +126 -0
- package/src/installation_preservation.test.ts +48 -0
- package/src/judgments/advice.test.ts +38 -0
- package/src/judgments/advice.ts +32 -0
- package/src/judgments/command.integration.test.ts +47 -0
- package/src/judgments/command.ts +90 -0
- package/src/judgments/constants.ts +55 -0
- package/src/judgments/evidence.test.ts +48 -0
- package/src/judgments/evidence.ts +34 -0
- package/src/judgments/files.test.ts +62 -0
- package/src/judgments/files.ts +73 -0
- package/src/judgments/questions.test.ts +19 -0
- package/src/judgments/questions.ts +85 -0
- package/src/judgments/replay.test.ts +43 -0
- package/src/judgments/replay.ts +66 -0
- package/src/judgments/runner.integration.test.ts +165 -0
- package/src/judgments/runner.ts +97 -0
- package/src/judgments/scan.test.ts +19 -0
- package/src/judgments/scan.ts +40 -0
- package/src/judgments/syntax.test.ts +115 -0
- package/src/judgments/syntax.ts +71 -0
- package/src/judgments/syntax_worker.ts +70 -0
- package/src/judgments/test_support.ts +60 -0
- package/src/judgments/transport.integration.test.ts +63 -0
- package/src/judgments/transport.ts +35 -0
- package/src/judgments/types.test.ts +43 -0
- package/src/judgments/types.ts +66 -0
- package/src/judgments/wire.test.ts +48 -0
- package/src/judgments/wire.ts +40 -0
- package/src/loaders.test.ts +13 -16
- package/src/loaders.ts +28 -10
- package/src/model.test.ts +20 -3
- package/src/model.ts +7 -18
- package/src/pack_hygiene.test.ts +42 -1
- package/src/pack_hygiene.ts +18 -8
- package/src/references.ts +15 -7
- package/src/release_inventory.test.ts +80 -0
- package/src/render.test.ts +1 -1
- package/src/render.ts +3 -8
- package/src/required_scanner.test.ts +36 -0
- package/src/review_poll.test.ts +158 -0
- package/src/scanner_scope.test.ts +202 -0
- package/src/setup.test.ts +9 -6
- package/src/spec_baseline.test.ts +1 -1
- package/src/spec_completion.test.ts +74 -0
- package/src/spec_obligations.ts +64 -0
- package/src/spec_rehearsal.test.ts +182 -0
- package/src/spec_template_io.test.ts +11 -9
- package/src/surfaces.test.ts +2 -0
- package/src/surfaces.ts +1 -1
- package/src/validation.ts +7 -4
- package/src/verify.test.ts +18 -2
- package/src/verify.ts +7 -3
package/src/cli.ts
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { printHelp } from "./cli_help";
|
|
6
|
+
import { selectPacks } from "./config_discovery";
|
|
7
|
+
import { CONFIG_PATH, managedDrift, readConfig, RepoConfig, staleVersion, writeConfig } from "./config";
|
|
6
8
|
import { GateReport, isGateName, recordOverride, runGate, runGates } from "./gates";
|
|
9
|
+
import { readInstallConfig } from "./installation/migration";
|
|
7
10
|
import { install, InstallResult } from "./install";
|
|
8
11
|
import { isString, OrlyError, readJsonObject, RulesModel } from "./model";
|
|
9
12
|
import { Renderer } from "./render";
|
|
@@ -33,9 +36,6 @@ const PR_GATE = "pr";
|
|
|
33
36
|
const ENGINE_MARKER = "evals/install/run.sh";
|
|
34
37
|
const VERIFY_COMMAND = "verify";
|
|
35
38
|
const SKILL_EVENT_COMMAND = "skill-event";
|
|
36
|
-
const TELEMETRY_CONFIG_KEY = "telemetry";
|
|
37
|
-
const ANONYMOUS_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "anonymous" });
|
|
38
|
-
const OFF_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "off" });
|
|
39
39
|
|
|
40
40
|
const { root, arguments: commandArguments } = parseRoot(Bun.argv.slice(2));
|
|
41
41
|
const startedAt = performance.now();
|
|
@@ -75,6 +75,16 @@ async function run(model: RulesModel, args: string[]): Promise<CliResult> {
|
|
|
75
75
|
if (command === "init") return cliResult(await materialise(model, rest, true));
|
|
76
76
|
if (command === "update") return cliResult(await materialise(model, rest, false));
|
|
77
77
|
if (command === VERIFY_COMMAND) return cliResult(await verify(model, rest));
|
|
78
|
+
if (command === "judge") {
|
|
79
|
+
const { judgmentCommand } = await import("./judgments/command");
|
|
80
|
+
return cliResult(await judgmentCommand(rest));
|
|
81
|
+
}
|
|
82
|
+
if (command === "lifecycle") {
|
|
83
|
+
if (rest.some((argument) => argument !== JSON_FLAG)) throw new OrlyError("usage: orly lifecycle [--json]");
|
|
84
|
+
const { lifecyclePlan } = await import("./execution/plan");
|
|
85
|
+
console.log(JSON.stringify(lifecyclePlan(projectRoot()), undefined, JSON_INDENT));
|
|
86
|
+
return cliResult(0);
|
|
87
|
+
}
|
|
78
88
|
if (command === "gate") return gate(model, rest);
|
|
79
89
|
if (command === "override") return cliResult(override(rest));
|
|
80
90
|
if (command === SKILL_EVENT_COMMAND) {
|
|
@@ -131,12 +141,12 @@ async function packageVersion(model: RulesModel): Promise<string> {
|
|
|
131
141
|
return manifest.version;
|
|
132
142
|
}
|
|
133
143
|
|
|
134
|
-
// Both verbs materialise the same way: the repository's own `.
|
|
144
|
+
// Both verbs materialise the same way: the repository's own `.orly/orly.json`
|
|
135
145
|
// names its packs and commands, so neither asks the caller who this repo is.
|
|
136
146
|
async function materialise(model: RulesModel, args: string[], isInit: boolean): Promise<number> {
|
|
137
147
|
model.validate();
|
|
138
148
|
const targetRoot = projectRoot();
|
|
139
|
-
const existing = await
|
|
149
|
+
const existing = await readInstallConfig(targetRoot);
|
|
140
150
|
if (!isInit && !existing) throw new OrlyError(`no ${CONFIG_PATH} here — run \`orly init\` first`);
|
|
141
151
|
const requested = optionalValues(args, WITH_FLAG);
|
|
142
152
|
// --dry-run changes nothing, and recording an opt-in pack is a change. It
|
|
@@ -146,9 +156,9 @@ async function materialise(model: RulesModel, args: string[], isInit: boolean):
|
|
|
146
156
|
// Otherwise the pack is a property of the repository, recorded before the
|
|
147
157
|
// render rather than passed through it: the next `orly update` in a
|
|
148
158
|
// teammate's clone selects the same set with no flag to remember.
|
|
149
|
-
await recordOptIn(model, targetRoot, existing, requested);
|
|
150
159
|
const result = await install(model, {
|
|
151
160
|
targetRoot,
|
|
161
|
+
requestedPacks: requested,
|
|
152
162
|
force: args.includes(FORCE_FLAG),
|
|
153
163
|
installHooks: !args.includes(NO_HOOKS_FLAG),
|
|
154
164
|
orlyVersion: await packageVersion(model),
|
|
@@ -210,7 +220,8 @@ async function doctorInstall(model: RulesModel): Promise<string[]> {
|
|
|
210
220
|
// What init would write, without writing it. Replaces the `render` verb: the
|
|
211
221
|
// preview belongs on the command you are about to run, not beside it.
|
|
212
222
|
async function preview(model: RulesModel, targetRoot: string, requested: string[] = []): Promise<number> {
|
|
213
|
-
const
|
|
223
|
+
const config = await readInstallConfig(targetRoot);
|
|
224
|
+
const local = { packs: selectPacks(model, targetRoot, config?.packs ?? [], new Set(config?.managed ?? [])), commands: config?.commands ?? {} };
|
|
214
225
|
// selectPacks validates the requested names and unions them with what the
|
|
215
226
|
// repository's own sources select — the same call the real run makes, minus
|
|
216
227
|
// the write.
|
|
@@ -220,20 +231,6 @@ async function preview(model: RulesModel, targetRoot: string, requested: string[
|
|
|
220
231
|
return 0;
|
|
221
232
|
}
|
|
222
233
|
|
|
223
|
-
// Opt-in packs never auto-select from file extensions, so naming one is the
|
|
224
|
-
// only way it lands. Validated against the registry here, before anything is
|
|
225
|
-
// recorded, so a typo names the available set instead of writing a config the
|
|
226
|
-
// next command rejects.
|
|
227
|
-
async function recordOptIn(model: RulesModel, targetRoot: string, existing: RepoConfig | undefined, requested: string[]): Promise<void> {
|
|
228
|
-
if (requested.length === 0) return;
|
|
229
|
-
const config = existing ?? await seedConfig(targetRoot);
|
|
230
|
-
const packs = new Set(config.packs);
|
|
231
|
-
for (const name of requested) packs.add(name);
|
|
232
|
-
// selectPacks throws OrlyError on an unknown name, listing what is available.
|
|
233
|
-
selectPacks(model, targetRoot, [...packs]);
|
|
234
|
-
await writeConfig(targetRoot, { ...config, packs: [...packs].sort() });
|
|
235
|
-
}
|
|
236
|
-
|
|
237
234
|
async function verify(model: RulesModel, args: string[]): Promise<number> {
|
|
238
235
|
requireEngineCheckout(model, VERIFY_COMMAND);
|
|
239
236
|
const checks = await verifyRenders(model);
|
|
@@ -290,56 +287,3 @@ function optionalValues(args: string[], name: string): string[] {
|
|
|
290
287
|
function cliResult(exitCode: number): CliResult {
|
|
291
288
|
return { exitCode };
|
|
292
289
|
}
|
|
293
|
-
|
|
294
|
-
function printHelp(): void {
|
|
295
|
-
console.log(`orly — prove the boundary; carry the rules
|
|
296
|
-
|
|
297
|
-
Gates (read-only; no PR without every criterion green or a recorded override):
|
|
298
|
-
orly gate [--accept-dirty] run work → verify → pr; stop at first red
|
|
299
|
-
orly gate <work|verify|pr> run one gate
|
|
300
|
-
work does this commit conform? the declared conform command; no git
|
|
301
|
-
state, so a commit hook's own dirty tree never blocks it
|
|
302
|
-
verify does the work hold up? configuration, docs language, and the
|
|
303
|
-
non-test verify.* set; unfinished Sections may be pushed
|
|
304
|
-
pr can this ship? branch, tree, pushed, every spec criterion, and
|
|
305
|
-
every declared verify.* command
|
|
306
|
-
orly override <CRITERION> --reason <REASON>
|
|
307
|
-
empty commit with an Orly-Override trailer
|
|
308
|
-
|
|
309
|
-
Install (the repository is the unit — no checkout of this package required):
|
|
310
|
-
orly init [--force] [--no-hooks] [--with <PACK>] [--dry-run] [--json]
|
|
311
|
-
materialise rules, gates, hooks, and a
|
|
312
|
-
seeded .oracle/orly.json. A repository that
|
|
313
|
-
already has an AGENTS.md keeps it: orly's
|
|
314
|
-
rules land as AGENTS.orly.md, reached by a
|
|
315
|
-
pointer block in the file you own. One
|
|
316
|
-
import line lands per runtime that needs
|
|
317
|
-
one (CLAUDE.md, opencode.json), so the
|
|
318
|
-
rules load rather than wait to be noticed;
|
|
319
|
-
a loader you wrote yourself is left alone.
|
|
320
|
-
orly update [--force] [--with <PACK>] [--dry-run] [--json]
|
|
321
|
-
re-materialise at the installed engine version
|
|
322
|
-
|
|
323
|
-
--with <PACK> record an opt-in pack (repeatable) in
|
|
324
|
-
.oracle/orly.json, so every clone selects it
|
|
325
|
-
--dry-run show what would be written; change nothing
|
|
326
|
-
|
|
327
|
-
orly doctor check this repository's installed rules
|
|
328
|
-
against what orly would write today
|
|
329
|
-
orly --version the installed package version
|
|
330
|
-
|
|
331
|
-
Usage telemetry (off by default):
|
|
332
|
-
Consent file: ~/.config/agentsfleet/orly/.orly.json
|
|
333
|
-
State-root override: AGENTSFLEET_STATE_DIR
|
|
334
|
-
Set ${ANONYMOUS_TELEMETRY_CONFIG} to send command, gate, and packaged Orly skill
|
|
335
|
-
names, outcome, failed criterion, duration, Orly version, operating system, architecture,
|
|
336
|
-
invocation type, timestamps, and random event, session, and installation IDs.
|
|
337
|
-
Set ${OFF_TELEMETRY_CONFIG} to write and send nothing.
|
|
338
|
-
ORLY_TELEMETRY=off|anonymous changes one process; ORLY_TELEMETRY_OFF=1 wins.
|
|
339
|
-
Orly never collects source, paths, repositories, branches, arguments, prompts,
|
|
340
|
-
output, environment values, raw errors, or personal and account details.
|
|
341
|
-
|
|
342
|
-
Ruleset authoring (an orly checkout only — refused from an installed package):
|
|
343
|
-
orly verify the rules render the same twice, and the
|
|
344
|
-
committed copy matches that render`);
|
|
345
|
-
}
|
package/src/cli_help.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const TELEMETRY_CONFIG_KEY = "telemetry";
|
|
2
|
+
const ANONYMOUS_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "anonymous" });
|
|
3
|
+
const OFF_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "off" });
|
|
4
|
+
|
|
5
|
+
const HELP_TEXT = `orly — prove the boundary; carry the rules
|
|
6
|
+
|
|
7
|
+
Gates (read-only; no PR without every criterion green or a recorded override):
|
|
8
|
+
orly gate [--accept-dirty] run work → verify → pr; stop at first red
|
|
9
|
+
orly gate <work|verify|pr> run one gate
|
|
10
|
+
work does this commit conform? the declared conform command; no git
|
|
11
|
+
state, so a commit hook's own dirty tree never blocks it
|
|
12
|
+
verify does the work hold up? configuration, docs language, and the
|
|
13
|
+
non-test verify.* set; unfinished Sections may be pushed
|
|
14
|
+
pr can this ship? branch, tree, pushed, every spec criterion, and
|
|
15
|
+
every declared verify.* command
|
|
16
|
+
orly override <CRITERION> --reason <REASON>
|
|
17
|
+
empty commit with an Orly-Override trailer
|
|
18
|
+
Commands default to 30 minutes and 64 mebibytes of combined output each.
|
|
19
|
+
Set limits.<command>.timeout_ms and output_bytes in .orly/orly.json to override.
|
|
20
|
+
Exceeding either limit fails the check and prints the saved output path.
|
|
21
|
+
|
|
22
|
+
Advice (explicit; hooks stay offline):
|
|
23
|
+
orly judge <plan|verify|review|document> --input <MANIFEST>
|
|
24
|
+
exact replay of bounded Jev advice
|
|
25
|
+
orly judge --help input selectors, questions and refresh
|
|
26
|
+
orly lifecycle [--json] inspect stages, declared local checks and
|
|
27
|
+
resource ownership; remote adapter disabled
|
|
28
|
+
|
|
29
|
+
Install (the repository is the unit — no checkout of this package required):
|
|
30
|
+
orly init [--force] [--no-hooks] [--with <PACK>] [--dry-run] [--json]
|
|
31
|
+
materialise rules, gates, hooks, and a
|
|
32
|
+
seeded .orly/orly.json. A repository that
|
|
33
|
+
already has an AGENTS.md keeps it: orly's
|
|
34
|
+
rules land as .orly/AGENTS.md, reached by a
|
|
35
|
+
pointer block in the file you own. One
|
|
36
|
+
import line lands per runtime that needs
|
|
37
|
+
one (CLAUDE.md, opencode.json), so the
|
|
38
|
+
rules load rather than wait to be noticed;
|
|
39
|
+
a loader you wrote yourself is left alone.
|
|
40
|
+
orly update [--force] [--with <PACK>] [--dry-run] [--json]
|
|
41
|
+
re-materialise at the installed engine version
|
|
42
|
+
|
|
43
|
+
--with <PACK> record an opt-in pack (repeatable) in
|
|
44
|
+
.orly/orly.json, so every clone selects it
|
|
45
|
+
--dry-run show what would be written; change nothing
|
|
46
|
+
|
|
47
|
+
orly doctor check this repository's installed rules
|
|
48
|
+
against what orly would write today
|
|
49
|
+
orly --version the installed package version
|
|
50
|
+
|
|
51
|
+
Usage telemetry (off by default):
|
|
52
|
+
Consent file: ~/.config/agentsfleet/orly/.orly.json
|
|
53
|
+
State-root override: AGENTSFLEET_STATE_DIR
|
|
54
|
+
Set ${ANONYMOUS_TELEMETRY_CONFIG} to send command, gate, and packaged Orly skill
|
|
55
|
+
names, outcome, failed criterion, duration, Orly version, operating system, architecture,
|
|
56
|
+
invocation type, timestamps, and random event, session, and installation IDs.
|
|
57
|
+
Set ${OFF_TELEMETRY_CONFIG} to write and send nothing.
|
|
58
|
+
ORLY_TELEMETRY=off|anonymous changes one process; ORLY_TELEMETRY_OFF=1 wins.
|
|
59
|
+
Orly never collects source, paths, repositories, branches, arguments, prompts,
|
|
60
|
+
output, environment values, raw errors, or personal and account details.
|
|
61
|
+
|
|
62
|
+
Ruleset authoring (an orly checkout only — refused from an installed package):
|
|
63
|
+
orly verify the rules render the same twice, and the
|
|
64
|
+
committed copy matches that render`;
|
|
65
|
+
|
|
66
|
+
export function printHelp(): void {
|
|
67
|
+
// logging: command help is the program interface.
|
|
68
|
+
console.log(HELP_TEXT);
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
4
|
+
export const DEFAULT_COMMAND_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
5
|
+
export const SUPERVISOR_GRACE_MS = 5000;
|
|
6
|
+
const MAX_COMMAND_TIMEOUT_MS = 2 ** 31 - 1 - SUPERVISOR_GRACE_MS;
|
|
7
|
+
export const DEFAULT_COMMAND_LIMITS = Object.freeze({ timeout_ms: DEFAULT_COMMAND_TIMEOUT_MS, output_bytes: DEFAULT_COMMAND_OUTPUT_BYTES });
|
|
8
|
+
export const commandLimitsSchema = z.object({
|
|
9
|
+
timeout_ms: z.number().int().positive().max(MAX_COMMAND_TIMEOUT_MS).optional(),
|
|
10
|
+
output_bytes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
|
|
11
|
+
}).strict();
|
|
12
|
+
export type CommandLimits = typeof DEFAULT_COMMAND_LIMITS;
|
|
13
|
+
export type LaneLimits = Record<string, z.infer<typeof commandLimitsSchema>>;
|
|
14
|
+
|
|
15
|
+
export function limitsFor(limits: LaneLimits | undefined, lane: string): CommandLimits {
|
|
16
|
+
return {
|
|
17
|
+
timeout_ms: limits?.[lane]?.timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
18
|
+
output_bytes: limits?.[lane]?.output_bytes ?? DEFAULT_COMMAND_OUTPUT_BYTES,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { cleanupTemporaryDirectories, ROOT, temporaryDirectory } from "./gates_test_support";
|
|
6
|
+
import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
7
|
+
|
|
8
|
+
const PROCESS_SOURCE = "src/command_process.ts";
|
|
9
|
+
const RESULT_FILE = "result.json";
|
|
10
|
+
const ENCODING = "utf8";
|
|
11
|
+
const TEST_TIMEOUT_MS = 5_000;
|
|
12
|
+
const WORKLOAD_TIMEOUT_MS = 10_000;
|
|
13
|
+
const CHILD_START_POLL_MS = 10;
|
|
14
|
+
const CHILD_START_ATTEMPTS = 200;
|
|
15
|
+
const REPEATED_STOP_REFUSAL = `const initialKill = process.kill;
|
|
16
|
+
const stoppedGroups = new Set<number>();
|
|
17
|
+
process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
|
|
18
|
+
if (pid < 0 && signal === "SIGKILL") {
|
|
19
|
+
if (stoppedGroups.has(pid)) throw Object.assign(new Error("group already stopped"), { code: "EPERM" });
|
|
20
|
+
stoppedGroups.add(pid);
|
|
21
|
+
}
|
|
22
|
+
return initialKill(pid, signal);
|
|
23
|
+
}) as typeof process.kill;
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
afterEach(cleanupTemporaryDirectories);
|
|
27
|
+
|
|
28
|
+
test("a failed pid receipt write stops the already started workload", () => {
|
|
29
|
+
const root = temporaryDirectory();
|
|
30
|
+
const pidFile = join(root, "owned.pid");
|
|
31
|
+
const script = join(root, "command_process.ts");
|
|
32
|
+
const source = readFileSync(join(ROOT, PROCESS_SOURCE), ENCODING);
|
|
33
|
+
const waitForChild = `for (let attempt = 0; attempt < ${CHILD_START_ATTEMPTS} && !await Bun.file(${JSON.stringify(pidFile)}).exists(); attempt++) await Bun.sleep(${CHILD_START_POLL_MS});`;
|
|
34
|
+
writeFileSync(script, source.replace("writeFileSync(join(request.receipt, COMMAND_FILES.pid),", `${waitForChild}\n writeFileSync(join(request.receipt, COMMAND_FILES.pid),`));
|
|
35
|
+
mkdirSync(join(root, "child.pid"));
|
|
36
|
+
const request = join(root, "request.json");
|
|
37
|
+
writeFileSync(request, JSON.stringify({ command: ["sh", "-c", `echo $$ > '${pidFile}'; exec sleep 10`], limits: { timeout_ms: WORKLOAD_TIMEOUT_MS, output_bytes: 4096 }, receipt: root, parent_pid: process.pid }));
|
|
38
|
+
let pid: number | undefined;
|
|
39
|
+
try {
|
|
40
|
+
const supervisor = Bun.spawnSync([process.execPath, script, request], { env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe", timeout: TEST_TIMEOUT_MS });
|
|
41
|
+
expect(supervisor.exitCode).not.toBe(0);
|
|
42
|
+
expect(supervisor.stderr.toString()).toContain("EISDIR");
|
|
43
|
+
expect(existsSync(pidFile)).toBeTrue();
|
|
44
|
+
const workloadPid = Number(readFileSync(pidFile, ENCODING));
|
|
45
|
+
pid = workloadPid;
|
|
46
|
+
expect(() => process.kill(workloadPid, 0)).toThrow();
|
|
47
|
+
} finally {
|
|
48
|
+
if (pid) { try { process.kill(-pid, "SIGKILL"); } catch { /* owned group already stopped */ } }
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a successful deadline stop is not repeated during supervisor cleanup", () => {
|
|
53
|
+
const root = temporaryDirectory();
|
|
54
|
+
const script = join(root, "command_process.ts");
|
|
55
|
+
writeFileSync(script, REPEATED_STOP_REFUSAL + readFileSync(join(ROOT, PROCESS_SOURCE), ENCODING));
|
|
56
|
+
const request = join(root, "request.json");
|
|
57
|
+
writeFileSync(request, JSON.stringify({ command: ["sh", "-c", "echo measured; sleep 3"], limits: { timeout_ms: 100, output_bytes: 4096 }, receipt: root, parent_pid: process.pid }));
|
|
58
|
+
const child = Bun.spawnSync([process.execPath, script, request], { env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe", timeout: TEST_TIMEOUT_MS });
|
|
59
|
+
expect(child.exitCode, child.stderr.toString()).toBe(0);
|
|
60
|
+
expect(child.stderr.toString()).toBe("");
|
|
61
|
+
expect(JSON.parse(readFileSync(join(root, RESULT_FILE), ENCODING)).failure).toBe("deadline exceeded");
|
|
62
|
+
expect(readFileSync(join(root, "stdout.log"), ENCODING)).toBe("measured\n");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("killing the gate owner stops its detached workload", async () => {
|
|
66
|
+
const root = temporaryDirectory();
|
|
67
|
+
const pidFile = join(root, "owned.pid");
|
|
68
|
+
const wrapper = join(root, "gate.ts");
|
|
69
|
+
writeFileSync(wrapper, `import { runCommand } from ${JSON.stringify(join(ROOT, "src/command_runner.ts"))}; runCommand(${JSON.stringify(root)}, ["sh", "-c", "echo $$ > owned.pid; sleep 10"], {timeout_ms: ${WORKLOAD_TIMEOUT_MS}, output_bytes: 4096});`);
|
|
70
|
+
const gate = Bun.spawn([process.execPath, wrapper], { cwd: root, env: { ...UNSCOPED_ENVIRONMENT, TMPDIR: root }, stdout: "pipe", stderr: "pipe" });
|
|
71
|
+
let pid: number | undefined;
|
|
72
|
+
try {
|
|
73
|
+
for (let attempt = 0; attempt < 200 && !existsSync(pidFile); attempt++) await Bun.sleep(10);
|
|
74
|
+
expect(existsSync(pidFile)).toBeTrue();
|
|
75
|
+
pid = Number(readFileSync(pidFile, ENCODING));
|
|
76
|
+
gate.kill("SIGKILL");
|
|
77
|
+
await gate.exited;
|
|
78
|
+
let alive = true;
|
|
79
|
+
for (let attempt = 0; attempt < 100 && alive; attempt++) {
|
|
80
|
+
await Bun.sleep(10);
|
|
81
|
+
try { process.kill(pid, 0); } catch { alive = false; }
|
|
82
|
+
}
|
|
83
|
+
expect(alive).toBeFalse();
|
|
84
|
+
} finally {
|
|
85
|
+
gate.kill("SIGKILL");
|
|
86
|
+
await gate.exited;
|
|
87
|
+
if (pid) { try { process.kill(-pid, "SIGKILL"); } catch { /* owned group already stopped */ } }
|
|
88
|
+
}
|
|
89
|
+
}, TEST_TIMEOUT_MS);
|
|
90
|
+
|
|
91
|
+
test.each(["SIGTERM", "SIGINT", "SIGHUP"] as const)("supervisor %s stops its owned workload", async (signal) => {
|
|
92
|
+
const root = temporaryDirectory();
|
|
93
|
+
const request = join(root, "request.json");
|
|
94
|
+
writeFileSync(request, JSON.stringify({ command: ["sh", "-c", "echo ready; sleep 10"], limits: { timeout_ms: WORKLOAD_TIMEOUT_MS, output_bytes: 4096 }, receipt: root, parent_pid: process.pid }));
|
|
95
|
+
const supervisor = Bun.spawn([process.execPath, join(ROOT, PROCESS_SOURCE), request], { env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe" });
|
|
96
|
+
try {
|
|
97
|
+
for (let attempt = 0; attempt < 200 && !existsSync(join(root, "child.pid")); attempt++) await Bun.sleep(10);
|
|
98
|
+
expect(existsSync(join(root, "child.pid"))).toBeTrue();
|
|
99
|
+
await Bun.sleep(20);
|
|
100
|
+
supervisor.kill(signal);
|
|
101
|
+
expect(await supervisor.exited).toBe(0);
|
|
102
|
+
expect(JSON.parse(readFileSync(join(root, RESULT_FILE), ENCODING)).failure).toBe(`supervisor received ${signal}`);
|
|
103
|
+
const pid = Number(readFileSync(join(root, "child.pid"), ENCODING));
|
|
104
|
+
expect(() => process.kill(pid, 0)).toThrow();
|
|
105
|
+
} finally {
|
|
106
|
+
supervisor.kill("SIGKILL");
|
|
107
|
+
await supervisor.exited;
|
|
108
|
+
}
|
|
109
|
+
}, TEST_TIMEOUT_MS);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { CommandLimits } from "./command_limits";
|
|
5
|
+
|
|
6
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
7
|
+
const TEXT_ENCODING = "utf8";
|
|
8
|
+
const KILL_SIGNAL = "SIGKILL";
|
|
9
|
+
const MISSING_PROCESS = "ESRCH";
|
|
10
|
+
const PIPE_OUTPUT = "pipe";
|
|
11
|
+
const OWNER_POLL_MS = 100;
|
|
12
|
+
const TERMINATION_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"] as const;
|
|
13
|
+
export const COMMAND_FILES = {
|
|
14
|
+
result: "result.json", pid: "child.pid", stdout: "stdout.log", stderr: "stderr.log",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
type Request = { command: string[]; limits: CommandLimits; receipt: string; parent_pid: number };
|
|
18
|
+
export type CommandResult = { exit_code: number | null; elapsed_ms: number; output_bytes: number; failure?: string };
|
|
19
|
+
|
|
20
|
+
export function stopOwnedGroup(pid: number): void {
|
|
21
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("invalid owned process group");
|
|
22
|
+
try { process.kill(-pid, KILL_SIGNAL); }
|
|
23
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== MISSING_PROCESS) throw error; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function capture(stream: ReadableStream<Uint8Array>, file: string, onBytes: (size: number) => void): Promise<void> {
|
|
27
|
+
for await (const chunk of stream) {
|
|
28
|
+
appendFileSync(file, chunk);
|
|
29
|
+
onBytes(chunk.byteLength);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function run(request: Request): Promise<CommandResult> {
|
|
34
|
+
const started = performance.now();
|
|
35
|
+
const stdout = join(request.receipt, COMMAND_FILES.stdout);
|
|
36
|
+
const stderr = join(request.receipt, COMMAND_FILES.stderr);
|
|
37
|
+
for (const file of [stdout, stderr]) writeFileSync(file, "", { mode: PRIVATE_FILE_MODE });
|
|
38
|
+
let child;
|
|
39
|
+
try {
|
|
40
|
+
child = Bun.spawn(request.command, { detached: true, stdin: "ignore", stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return { exit_code: null, elapsed_ms: Math.ceil(performance.now() - started), output_bytes: 0, failure: `${request.command[0] ?? "command"} could not be run: ${error instanceof Error ? error.message : String(error)}` };
|
|
43
|
+
}
|
|
44
|
+
let stopped = false;
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(join(request.receipt, COMMAND_FILES.pid), String(child.pid), { mode: PRIVATE_FILE_MODE });
|
|
47
|
+
let outputBytes = 0;
|
|
48
|
+
let failure: string | undefined;
|
|
49
|
+
const stop = (reason: string) => {
|
|
50
|
+
failure ??= reason;
|
|
51
|
+
if (stopped) return;
|
|
52
|
+
stopOwnedGroup(child.pid);
|
|
53
|
+
stopped = true;
|
|
54
|
+
};
|
|
55
|
+
const timer = setTimeout(() => stop("deadline exceeded"), request.limits.timeout_ms);
|
|
56
|
+
const owner = setInterval(() => {
|
|
57
|
+
try { process.kill(request.parent_pid, 0); }
|
|
58
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === MISSING_PROCESS) stop("gate owner exited"); }
|
|
59
|
+
}, OWNER_POLL_MS);
|
|
60
|
+
const signals = TERMINATION_SIGNALS.map((signal) => {
|
|
61
|
+
const handler = () => stop(`supervisor received ${signal}`);
|
|
62
|
+
process.on(signal, handler);
|
|
63
|
+
return { signal, handler };
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
const onBytes = (size: number) => {
|
|
67
|
+
outputBytes += size;
|
|
68
|
+
if (outputBytes > request.limits.output_bytes) stop("output limit exceeded");
|
|
69
|
+
};
|
|
70
|
+
await Promise.all([capture(child.stdout, stdout, onBytes), capture(child.stderr, stderr, onBytes), child.exited]);
|
|
71
|
+
if (child.signalCode) failure ??= `stopped by ${child.signalCode}`;
|
|
72
|
+
return { exit_code: child.exitCode, elapsed_ms: Math.ceil(performance.now() - started), output_bytes: outputBytes, ...(failure ? { failure } : {}) };
|
|
73
|
+
} finally {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
clearInterval(owner);
|
|
76
|
+
for (const { signal, handler } of signals) process.off(signal, handler);
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
if (!stopped) stopOwnedGroup(child.pid);
|
|
80
|
+
await child.exited;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (import.meta.main) {
|
|
85
|
+
const request: Request = JSON.parse(readFileSync(Bun.argv[2]!, TEXT_ENCODING));
|
|
86
|
+
const result = await run(request);
|
|
87
|
+
writeFileSync(join(request.receipt, COMMAND_FILES.result), JSON.stringify(result), { mode: PRIVATE_FILE_MODE });
|
|
88
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { runCommand } from "./command_runner";
|
|
7
|
+
import { criteriaFor } from "./criteria";
|
|
8
|
+
import { CONFIG_PATH } from "./config";
|
|
9
|
+
import { cleanupTemporaryDirectories, modelFor, newRepository } from "./gates_test_support";
|
|
10
|
+
|
|
11
|
+
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
12
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
13
|
+
const PERMISSION_MASK = 0o777;
|
|
14
|
+
const CHILD_FILE = "child.pid";
|
|
15
|
+
const UNIT_LANE = "verify.unit";
|
|
16
|
+
const roots: string[] = [];
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
cleanupTemporaryDirectories();
|
|
20
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function scratch(): string {
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), "orly-command-test-"));
|
|
25
|
+
roots.push(root);
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function receipt(detail: string): string {
|
|
30
|
+
const path = detail.split("complete captured output: ")[1];
|
|
31
|
+
expect(path).toBeDefined();
|
|
32
|
+
roots.push(path!);
|
|
33
|
+
expect(statSync(path!).mode & PERMISSION_MASK).toBe(PRIVATE_DIRECTORY_MODE);
|
|
34
|
+
for (const file of ["stdout.log", "stderr.log"]) expect(statSync(join(path!, file)).mode & PERMISSION_MASK).toBe(PRIVATE_FILE_MODE);
|
|
35
|
+
return path!;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function expectChildStopped(root: string): Promise<void> {
|
|
39
|
+
expect(existsSync(join(root, CHILD_FILE))).toBeTrue();
|
|
40
|
+
const pid = Number(readFileSync(join(root, CHILD_FILE), "utf8").trim());
|
|
41
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
42
|
+
try { process.kill(pid, 0); } catch { return; }
|
|
43
|
+
await Bun.sleep(10);
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`owned child ${pid} survived the command limit`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("a deadline stops the owned child group and keeps captured output", async () => {
|
|
49
|
+
const root = scratch();
|
|
50
|
+
const result = runCommand(root, ["sh", "-c", `sleep 30 & echo $! > ${CHILD_FILE}; echo started; echo detail >&2; wait`], { timeout_ms: 300, output_bytes: 4096 });
|
|
51
|
+
expect(result.ok).toBeFalse();
|
|
52
|
+
expect(result.detail).toContain("deadline exceeded");
|
|
53
|
+
expect(result.detail).toContain("300 ms deadline");
|
|
54
|
+
const path = receipt(result.detail);
|
|
55
|
+
expect(readFileSync(join(path, "stdout.log"), "utf8")).toBe("started\n");
|
|
56
|
+
expect(readFileSync(join(path, "stderr.log"), "utf8")).toBe("detail\n");
|
|
57
|
+
await expectChildStopped(root);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("the output budget covers both streams and stops the owned child group", async () => {
|
|
61
|
+
const root = scratch();
|
|
62
|
+
const chunk = "123456789012345678901234";
|
|
63
|
+
const started = performance.now();
|
|
64
|
+
const result = runCommand(root, ["sh", "-c", `sleep 30 & echo $! > ${CHILD_FILE}; printf ${chunk}; printf ${chunk} >&2; wait`], { timeout_ms: 3000, output_bytes: 32 });
|
|
65
|
+
expect(performance.now() - started).toBeLessThan(1500);
|
|
66
|
+
expect(result.ok).toBeFalse();
|
|
67
|
+
expect(result.detail).toContain("output limit exceeded");
|
|
68
|
+
expect(result.detail).toContain("48 bytes / 32 bytes");
|
|
69
|
+
const path = receipt(result.detail);
|
|
70
|
+
expect(readFileSync(join(path, "stdout.log"), "utf8")).toBe(chunk);
|
|
71
|
+
expect(readFileSync(join(path, "stderr.log"), "utf8")).toBe(chunk);
|
|
72
|
+
await expectChildStopped(root);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("an explicit lane override applies through the public gate criteria", async () => {
|
|
76
|
+
const root = newRepository();
|
|
77
|
+
const model = await modelFor(root);
|
|
78
|
+
writeFileSync(join(root, CONFIG_PATH), JSON.stringify({
|
|
79
|
+
schema_version: 1, commands: { conform: [["true"]], [UNIT_LANE]: [["sh", "-c", "echo measured; sleep 3"]] },
|
|
80
|
+
limits: { [UNIT_LANE]: { timeout_ms: 200, output_bytes: 4096 } },
|
|
81
|
+
}));
|
|
82
|
+
const context = { root, model, acceptDirty: false };
|
|
83
|
+
const check = criteriaFor("pr", context).find((entry) => entry.name === `cmd.${UNIT_LANE}`)!;
|
|
84
|
+
const result = check.evaluate(context);
|
|
85
|
+
expect(result.ok).toBeFalse();
|
|
86
|
+
expect(result.detail).toContain("200 ms deadline");
|
|
87
|
+
expect(readFileSync(join(receipt(result.detail), "stdout.log"), "utf8")).toBe("measured\n");
|
|
88
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_COMMAND_LIMITS, SUPERVISOR_GRACE_MS, type CommandLimits } from "./command_limits";
|
|
6
|
+
import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
7
|
+
import { COMMAND_FILES, stopOwnedGroup, type CommandResult } from "./command_process";
|
|
8
|
+
import type { Verdict } from "./criteria_support";
|
|
9
|
+
|
|
10
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
11
|
+
const TEXT_ENCODING = "utf8";
|
|
12
|
+
const PIPE_OUTPUT = "pipe";
|
|
13
|
+
const RECEIPT_PREFIX = "orly-command-";
|
|
14
|
+
|
|
15
|
+
export function runCommand(root: string, command: string[], limits: CommandLimits = DEFAULT_COMMAND_LIMITS): Verdict {
|
|
16
|
+
const receipt = mkdtempSync(join(tmpdir(), RECEIPT_PREFIX));
|
|
17
|
+
const request = join(receipt, "request.json");
|
|
18
|
+
writeFileSync(request, JSON.stringify({ command, limits, receipt, parent_pid: process.pid }), { mode: PRIVATE_FILE_MODE });
|
|
19
|
+
const supervisor = Bun.spawnSync([process.execPath, join(import.meta.dir, "command_process.ts"), request], {
|
|
20
|
+
cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT,
|
|
21
|
+
detached: true, timeout: limits.timeout_ms + SUPERVISOR_GRACE_MS, killSignal: "SIGKILL",
|
|
22
|
+
});
|
|
23
|
+
if (supervisor.exitCode !== 0) {
|
|
24
|
+
stopOwnedGroup(supervisor.pid);
|
|
25
|
+
try { stopOwnedGroup(Number(readFileSync(join(receipt, COMMAND_FILES.pid), TEXT_ENCODING))); } catch { /* child may not have started */ }
|
|
26
|
+
return { ok: false, detail: `command supervisor failed (${supervisor.signalCode ?? supervisor.exitCode}); complete captured output: ${receipt}; ${supervisor.stderr.toString().trim()}` };
|
|
27
|
+
}
|
|
28
|
+
const result: CommandResult = JSON.parse(readFileSync(join(receipt, COMMAND_FILES.result), TEXT_ENCODING));
|
|
29
|
+
if (result.failure) return { ok: false, detail: `${result.failure}; ${result.elapsed_ms} ms / ${limits.timeout_ms} ms deadline; ${result.output_bytes} bytes / ${limits.output_bytes} bytes output budget; complete captured output: ${receipt}` };
|
|
30
|
+
const output = [COMMAND_FILES.stdout, COMMAND_FILES.stderr].map((file) => readFileSync(join(receipt, file), TEXT_ENCODING)).filter(Boolean).join("\n").trim();
|
|
31
|
+
rmSync(receipt, { recursive: true, force: true });
|
|
32
|
+
return { ok: result.exit_code === 0, detail: output ? `exit ${result.exit_code}:\n${output}` : result.exit_code === 0 ? "exit 0" : `exit ${result.exit_code}: no output` };
|
|
33
|
+
}
|