axstack 0.31.0 → 0.33.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/docs/host-operations.md +72 -24
- package/docs/workflows.md +194 -35
- package/package.json +1 -1
- package/skills/axstack/references/automations.md +9 -12
- package/skills/axstack/references/autopilot.md +19 -4
- package/skills/axstack/references/pr-shape.md +6 -5
- package/skills/axstack/references/run-record.md +8 -16
- package/skills/axstack/references/t3-runtime.md +17 -20
- package/skills/axstack/scripts/dispatch-plan.js +56 -27
- package/skills/axstack/scripts/pr-shape.js +134 -0
- package/skills/axstack/scripts/readiness-run.js +161 -0
- package/skills/axstack/scripts/readiness.js +420 -0
- package/skills/axstack/scripts/run-init.js +105 -0
- package/skills/axstack-audit/SKILL.md +1 -1
- package/skills/axstack-implement/SKILL.md +20 -9
- package/skills/axstack-improve/SKILL.md +17 -0
- package/skills/axstack-relay/SKILL.md +196 -42
- package/skills/axstack-relay/hermes/hermes-skill.md +52 -11
- package/skills/axstack-review/SKILL.md +12 -3
- package/skills/axstack-watch/SKILL.md +137 -47
- package/skills/axstack-watch/references/watch-runtime.md +13 -4
- package/skills/axstack-relay/hermes/axstack-reply.sh +0 -49
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
|
|
3
|
-
const flags = ['--role', '--roles', '--capabilities', '--picker', '--kind', '--verify'];
|
|
3
|
+
const flags = ['--role', '--roles', '--capabilities', '--picker', '--kind', '--verify', '--out'];
|
|
4
4
|
const nonempty = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
5
5
|
const help = `Usage: bun dispatch-plan.js --role <id> --roles <roles.json>
|
|
6
6
|
--capabilities <saved.json> --picker <picker.json> --kind task|launch
|
|
7
|
-
[--verify <configuration.json>]
|
|
7
|
+
[--verify <configuration.json>] [--out <report.json>]
|
|
8
|
+
Example: bun dispatch-plan.js --role axstack-author --roles roles.json --capabilities capabilities.json --picker picker.json --kind launch
|
|
8
9
|
|
|
9
10
|
--role Role ID from the supplied snapshot
|
|
10
11
|
--roles Installed-style roles.json (version 1, roles array)
|
|
@@ -12,9 +13,13 @@ const help = `Usage: bun dispatch-plan.js --role <id> --roles <roles.json>
|
|
|
12
13
|
--picker Saved picker JSON; uses chosenInstanceId
|
|
13
14
|
--kind task emits target; launch emits modelSelection
|
|
14
15
|
--verify Compare t3_thread_configuration JSON; print MATCH or each mismatch
|
|
16
|
+
--out Write full JSON here and print input/path/omitted-count summary
|
|
15
17
|
--help Show all flags and exit codes
|
|
16
18
|
|
|
17
|
-
Read-only: reads supplied JSON, spawns adjacent resolve-models.js, writes stdout/stderr.
|
|
19
|
+
Read-only inputs: reads supplied JSON, spawns adjacent resolve-models.js, writes stdout/stderr.
|
|
20
|
+
Writes only the caller's --out path when supplied; no retained state.
|
|
21
|
+
Results name input paths. Plan output over 4096 bytes requires --out.
|
|
22
|
+
Large verification retains its exit code, prints mismatches and advises --out on stderr.
|
|
18
23
|
Never dispatches or changes snapshots. Reuse recorded exact model pins as given.
|
|
19
24
|
Exit codes: 0 ok, 1 error, 2 hold (including incomplete read-back).
|
|
20
25
|
`;
|
|
@@ -27,20 +32,23 @@ function argumentsFrom(args) {
|
|
|
27
32
|
const options = {};
|
|
28
33
|
for (let i = 0; i < args.length; i += 2) {
|
|
29
34
|
const flag = args[i];
|
|
30
|
-
if (!flags.includes(flag)) throw new Error(`unexpected argument ${flag}`);
|
|
35
|
+
if (!flags.includes(flag)) throw new Error(`unexpected argument ${flag}; use --help`);
|
|
31
36
|
const value = args[i + 1];
|
|
32
|
-
if (!nonempty(value) || value.startsWith('--')) throw new Error(`expected value after ${flag}
|
|
33
|
-
if (flag in options) throw new Error(`duplicate argument ${flag}`);
|
|
37
|
+
if (!nonempty(value) || value.startsWith('--')) throw new Error(`expected value after ${flag}; pass ${flag} <value>`);
|
|
38
|
+
if (flag in options) throw new Error(`duplicate argument ${flag}; pass it once`);
|
|
34
39
|
options[flag] = value;
|
|
35
40
|
}
|
|
36
|
-
for (const flag of flags.filter((flag) =>
|
|
37
|
-
if (!options[flag]) throw new Error(`expected ${flag}
|
|
41
|
+
for (const flag of flags.filter((flag) => !['--verify', '--out'].includes(flag))) {
|
|
42
|
+
if (!options[flag]) throw new Error(`expected ${flag}; pass ${flag} <value>`);
|
|
38
43
|
}
|
|
39
|
-
if (!['task', 'launch'].includes(options['--kind'])) throw new Error('expected --kind task|launch');
|
|
44
|
+
if (!['task', 'launch'].includes(options['--kind'])) throw new Error('expected --kind task|launch; pass --kind task or --kind launch');
|
|
40
45
|
return options;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
|
|
48
|
+
function readJSON(path) {
|
|
49
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
50
|
+
catch (error) { throw new Error(`${error.message}; pass a readable, valid JSON file: ${path}`); }
|
|
51
|
+
}
|
|
44
52
|
|
|
45
53
|
function normalizedOptions(options) {
|
|
46
54
|
const entries = Array.isArray(options) ? options.map((option) => [option?.id, option?.value])
|
|
@@ -68,19 +76,36 @@ function verify(configuration, instanceId, provider, model, options) {
|
|
|
68
76
|
compare('options', options, selection?.options,
|
|
69
77
|
normalizedOptions(options) === normalizedOptions(selection?.options));
|
|
70
78
|
compare('runtimeMode', 'full-access', configuration?.runtimeMode);
|
|
71
|
-
|
|
72
|
-
return mismatches.length ? 2 : 0;
|
|
79
|
+
return mismatches;
|
|
73
80
|
}
|
|
74
81
|
|
|
75
|
-
function
|
|
76
|
-
if (
|
|
77
|
-
|
|
82
|
+
function plan(argv, io) {
|
|
83
|
+
if (argv.includes('--help')) {
|
|
84
|
+
io.stdout(help);
|
|
78
85
|
return 0;
|
|
79
86
|
}
|
|
80
|
-
const args = argumentsFrom(
|
|
87
|
+
const args = argumentsFrom(argv);
|
|
88
|
+
const inputs = Object.fromEntries(Object.entries(args).filter(([flag]) => flag !== '--out').map(([flag, value]) => [flag.slice(2), value]));
|
|
89
|
+
const emit = (result) => {
|
|
90
|
+
const full = { inputs, ...result };
|
|
91
|
+
const inline = result.mismatches
|
|
92
|
+
? `${JSON.stringify({ inputs })}\n${result.mismatches.join('\n') || 'MATCH'}\n` : `${JSON.stringify(full)}\n`;
|
|
93
|
+
if (!args['--out']) {
|
|
94
|
+
if (new TextEncoder().encode(inline).length > 4096) {
|
|
95
|
+
if (!result.mismatches) throw new Error('large output; pass --out <report.json> to save full JSON');
|
|
96
|
+
io.stderr('Large verification output; pass --out <path>.json for the full list\n');
|
|
97
|
+
}
|
|
98
|
+
io.stdout(inline);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try { writeFileSync(args['--out'], `${JSON.stringify(full, null, 2)}\n`); }
|
|
102
|
+
catch (error) { throw new Error(`${error.message}; pass --out <writable report.json path>`); }
|
|
103
|
+
io.stdout(`${JSON.stringify({ inputs, reportPath: args['--out'],
|
|
104
|
+
omittedPayloadFields: Object.keys(result).length, omittedMismatches: result.mismatches?.length ?? 0 })}\n`);
|
|
105
|
+
};
|
|
81
106
|
const snapshot = readJSON(args['--roles']);
|
|
82
107
|
if (snapshot?.version !== 1 || !Array.isArray(snapshot.roles)) {
|
|
83
|
-
throw new Error('roles snapshot must have version 1 and a roles array');
|
|
108
|
+
throw new Error('roles snapshot must have version 1 and a roles array; pass --roles <valid roles.json>');
|
|
84
109
|
}
|
|
85
110
|
const roles = snapshot.roles.filter((role) => role?.id === args['--role']);
|
|
86
111
|
if (roles.length !== 1) hold(`role ${args['--role']} unavailable or ambiguous`);
|
|
@@ -100,7 +125,7 @@ function main() {
|
|
|
100
125
|
const resolved = Bun.spawnSync([process.execPath, `${import.meta.dir}/resolve-models.js`,
|
|
101
126
|
'--provider', role.provider, '--capabilities', args['--capabilities'],
|
|
102
127
|
'--effort', role.thinkingOptionId, ...modelArgs], { stdout: 'pipe', stderr: 'pipe' });
|
|
103
|
-
if (resolved.stderr.length)
|
|
128
|
+
if (resolved.stderr.length) io.stderr(resolved.stderr.toString());
|
|
104
129
|
if (resolved.exitCode !== 0) return resolved.exitCode === 1 ? 2 : 1;
|
|
105
130
|
const { model, effortOption, providerInstanceId } = JSON.parse(resolved.stdout.toString());
|
|
106
131
|
const chosen = readJSON(args['--capabilities']).providers
|
|
@@ -117,18 +142,22 @@ function main() {
|
|
|
117
142
|
}
|
|
118
143
|
const options = effortOption ? [effortOption] : [];
|
|
119
144
|
if (args['--verify']) {
|
|
120
|
-
|
|
145
|
+
const mismatches = verify(readJSON(args['--verify']), picker.chosenInstanceId, providerInstanceId, model, options);
|
|
146
|
+
emit({ mismatches });
|
|
147
|
+
return mismatches.length ? 2 : 0;
|
|
121
148
|
}
|
|
122
149
|
const selection = { [args['--kind'] === 'launch' ? 'instanceId' : 'providerInstanceId']: picker.chosenInstanceId,
|
|
123
150
|
model, options };
|
|
124
|
-
|
|
125
|
-
runtimeMode: 'full-access' }));
|
|
151
|
+
emit({ [args['--kind'] === 'launch' ? 'modelSelection' : 'target']: selection, runtimeMode: 'full-access' });
|
|
126
152
|
return 0;
|
|
127
153
|
}
|
|
128
154
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
155
|
+
export function main(argv, io = { stdout: (text) => process.stdout.write(text), stderr: (text) => process.stderr.write(text) }) {
|
|
156
|
+
try { return plan(argv, io); }
|
|
157
|
+
catch (error) {
|
|
158
|
+
io.stderr(`dispatch-plan ${error.exitCode === 2 ? 'hold' : 'error'}: ${error.message}\n`);
|
|
159
|
+
return error.exitCode ?? 1;
|
|
160
|
+
}
|
|
134
161
|
}
|
|
162
|
+
|
|
163
|
+
if (import.meta.main) process.exitCode = main(process.argv.slice(2));
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const lockfiles = ['bun.lock', 'bun.lockb', 'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock'];
|
|
4
|
+
const help = `Usage: bun scripts/pr-shape.js --base <ref> --head <ref>
|
|
5
|
+
Example: bun scripts/pr-shape.js --base main --head HEAD
|
|
6
|
+
|
|
7
|
+
--base Required actual PR base (parent branch for a stacked child)
|
|
8
|
+
--head Required candidate commit or branch
|
|
9
|
+
--help Show flags, example, bucket definitions and exit codes
|
|
10
|
+
|
|
11
|
+
Read-only Git; writes nothing, retains no state. Prints a bounded JSON summary:
|
|
12
|
+
input refs and resolved SHAs, merge-base, full additions/deletions, rename and
|
|
13
|
+
binary counts, and overlapping lockfile/generated buckets, with status and
|
|
14
|
+
reproducing argv plus calculation instructions. No path inventory is emitted.
|
|
15
|
+
Lockfiles match these exact basenames at any depth: ${lockfiles.join(', ')}.
|
|
16
|
+
Generated means linguist-generated set or true in .gitattributes at the head;
|
|
17
|
+
uses the destination path for renames. Unset, false and unspecified are excluded.
|
|
18
|
+
Ignores system/global attributes; local info/attributes causes an unknown bucket.
|
|
19
|
+
Never subtract buckets; formatter-only changes require manual disclosure.
|
|
20
|
+
No cohesion or approval verdict. Exit codes: 0 measured, 1 input/Git error,
|
|
21
|
+
2 incomplete (no merge-base or unavailable committed-attribute evidence).
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
function optionsFrom(argv) {
|
|
25
|
+
const options = {};
|
|
26
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
27
|
+
const flag = argv[i];
|
|
28
|
+
if (!['--base', '--head'].includes(flag)) throw new Error(`unexpected argument ${flag}; use --help`);
|
|
29
|
+
const value = argv[i + 1];
|
|
30
|
+
if (!value || value.startsWith('-')) throw new Error(`missing ref; pass ${flag} <commit-or-branch>`);
|
|
31
|
+
if (flag in options) throw new Error(`duplicate ${flag}; pass it once`);
|
|
32
|
+
options[flag] = value;
|
|
33
|
+
}
|
|
34
|
+
for (const flag of ['--base', '--head']) {
|
|
35
|
+
if (!options[flag]) throw new Error(`missing ref; pass ${flag} <commit-or-branch>`);
|
|
36
|
+
}
|
|
37
|
+
return options;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const command = (...args) => ['git', '--no-optional-locks', '-c', 'core.attributesFile=/dev/null', ...args];
|
|
41
|
+
function run(argv, input) {
|
|
42
|
+
const result = Bun.spawnSync(argv, { stdin: input === undefined ? 'ignore' : new TextEncoder().encode(input),
|
|
43
|
+
stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_ATTR_NOSYSTEM: '1' } });
|
|
44
|
+
return { code: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString() };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function numstat(text) {
|
|
48
|
+
const tokens = text.split('\0');
|
|
49
|
+
const rows = [];
|
|
50
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
51
|
+
const firstTab = tokens[i].indexOf('\t'), secondTab = tokens[i].indexOf('\t', firstTab + 1);
|
|
52
|
+
const added = tokens[i].slice(0, firstTab), deleted = tokens[i].slice(firstTab + 1, secondTab);
|
|
53
|
+
let path = tokens[i].slice(secondTab + 1);
|
|
54
|
+
const renamed = path === '';
|
|
55
|
+
if (renamed) { i++; path = tokens[++i]; } // -z rename: empty path, old path, new path.
|
|
56
|
+
rows.push({ path, renamed, binary: added === '-', additions: added === '-' ? 0 : Number(added),
|
|
57
|
+
deletions: deleted === '-' ? 0 : Number(deleted) });
|
|
58
|
+
}
|
|
59
|
+
return rows;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function summarize(rows, evidence) {
|
|
63
|
+
return { status: 'pass', files: rows.length,
|
|
64
|
+
additions: rows.reduce((sum, row) => sum + row.additions, 0),
|
|
65
|
+
deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
|
|
66
|
+
renames: rows.filter((row) => row.renamed).length,
|
|
67
|
+
binaries: rows.filter((row) => row.binary).length,
|
|
68
|
+
evidence: { ...evidence, calculations: {
|
|
69
|
+
files: 'count selected numstat records', additions: 'sum numeric column 1 of selected records',
|
|
70
|
+
deletions: 'sum numeric column 2 of selected records',
|
|
71
|
+
renames: 'count selected -z records with empty path followed by old/new NUL paths',
|
|
72
|
+
binaries: 'count selected records with - in both numeric columns (no estimated lines)',
|
|
73
|
+
} } };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function main(argv, io = { stdout: (text) => process.stdout.write(text), stderr: (text) => process.stderr.write(text) }) {
|
|
77
|
+
try {
|
|
78
|
+
if (argv.includes('--help')) { io.stdout(help); return 0; }
|
|
79
|
+
const options = optionsFrom(argv);
|
|
80
|
+
const root = run(command('rev-parse', '--show-toplevel'));
|
|
81
|
+
if (root.code) throw new Error('run this helper from a Git checkout');
|
|
82
|
+
const repo = root.stdout.replace(/\n$/, '');
|
|
83
|
+
const gitCommand = (...args) => command('-C', repo, ...args);
|
|
84
|
+
const resolve = (flag) => {
|
|
85
|
+
const result = run(gitCommand('rev-parse', '--verify', '--end-of-options', `${options[flag]}^{commit}`));
|
|
86
|
+
if (result.code) throw new Error(`unresolved ${flag}; pass ${flag} <existing-commit-or-branch>`);
|
|
87
|
+
return result.stdout.trim();
|
|
88
|
+
};
|
|
89
|
+
const inputs = { repo, baseRef: options['--base'], headRef: options['--head'], base: resolve('--base'), head: resolve('--head') };
|
|
90
|
+
const mergeCommand = gitCommand('merge-base', inputs.base, inputs.head);
|
|
91
|
+
const merge = run(mergeCommand);
|
|
92
|
+
if (merge.code) {
|
|
93
|
+
const unknown = { status: 'unknown', evidence: { commands: [mergeCommand], reason: merge.stderr.trim() || 'no common ancestor' } };
|
|
94
|
+
io.stdout(`${JSON.stringify({ inputs, mergeBase: { ...unknown, value: null }, totals: unknown,
|
|
95
|
+
buckets: { lockfiles: unknown, generated: unknown } })}\n`);
|
|
96
|
+
return 2;
|
|
97
|
+
}
|
|
98
|
+
const mergeBase = merge.stdout.trim();
|
|
99
|
+
const diffCommand = gitCommand('diff', '-M', '--no-relative', '--no-ext-diff', '--no-textconv', '--numstat', '-z', `${mergeBase}..${inputs.head}`, '--');
|
|
100
|
+
const diff = run(diffCommand);
|
|
101
|
+
if (diff.code) throw new Error(`diff unavailable; verify --base and --head refer to readable commits: ${diff.stderr.trim()}`);
|
|
102
|
+
const rows = numstat(diff.stdout);
|
|
103
|
+
const attrCommand = gitCommand('check-attr', `--source=${inputs.head}`, '--stdin', '-z', 'linguist-generated');
|
|
104
|
+
const attrEvidence = { commands: [diffCommand, attrCommand], environment: { GIT_ATTR_NOSYSTEM: '1' },
|
|
105
|
+
stdin: 'feed check-attr the NUL-separated destination paths from the full numstat records',
|
|
106
|
+
select: 'destination paths whose head linguist-generated value is set or true; never filter diff pathspecs' };
|
|
107
|
+
const infoPath = run(gitCommand('rev-parse', '--path-format=absolute', '--git-path', 'info/attributes')).stdout.replace(/\n$/, '');
|
|
108
|
+
// Git still reads info/attributes with --source; do not claim these are committed facts.
|
|
109
|
+
const attrs = existsSync(infoPath) ? null : run(attrCommand, rows.map((row) => row.path).join('\0') + (rows.length ? '\0' : ''));
|
|
110
|
+
let generated;
|
|
111
|
+
if (!attrs || attrs.code) {
|
|
112
|
+
generated = { status: 'unknown', evidence: { ...attrEvidence,
|
|
113
|
+
reason: !attrs ? `local attribute override exists: ${infoPath}` : `committed attributes unavailable: ${attrs.stderr.trim()}` } };
|
|
114
|
+
} else {
|
|
115
|
+
const values = attrs.stdout.split('\0'), paths = new Set();
|
|
116
|
+
for (let i = 0; i < values.length - 1; i += 3) {
|
|
117
|
+
if (['set', 'true'].includes(values[i + 2])) paths.add(values[i]);
|
|
118
|
+
}
|
|
119
|
+
generated = summarize(rows.filter((row) => paths.has(row.path)), attrEvidence);
|
|
120
|
+
}
|
|
121
|
+
io.stdout(`${JSON.stringify({ inputs, mergeBase: { status: 'pass', value: mergeBase, evidence: { commands: [mergeCommand] } },
|
|
122
|
+
totals: summarize(rows, { commands: [diffCommand], select: 'all records; no exclusions' }),
|
|
123
|
+
buckets: { lockfiles: summarize(rows.filter((row) => lockfiles.includes(row.path.split('/').at(-1))),
|
|
124
|
+
{ commands: [diffCommand], select: 'destination basename is exactly one of lockfileNames', lockfileNames: lockfiles }), generated },
|
|
125
|
+
disclosures: 'Buckets may overlap. Never subtract them from totals. Formatter-only changes require manual disclosure; cohesion is a manual judgment.',
|
|
126
|
+
})}\n`);
|
|
127
|
+
return generated.status === 'unknown' ? 2 : 0;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
io.stderr(`pr-shape error: ${error.message}\n`);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (import.meta.main) process.exitCode = main(process.argv.slice(2));
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { chmodSync, closeSync, lstatSync, mkdirSync, mkdtempSync, openSync, realpathSync, rmSync, writeFileSync, writeSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const installs = {
|
|
4
|
+
bun: ['install', '--frozen-lockfile'], npm: ['ci'],
|
|
5
|
+
pnpm: ['install', '--frozen-lockfile'], yarn: ['install', '--immutable'],
|
|
6
|
+
};
|
|
7
|
+
const groups = [['env.build', ['build']], ['testing.test', ['test']], ['testing.lint-typecheck', ['lint', 'typecheck']]];
|
|
8
|
+
const drainGraceMs = 200;
|
|
9
|
+
// No shell interpolation, and the wrapper preserves the command's allowlisted env.
|
|
10
|
+
const networkWrapper = `const ip = Bun.spawnSync([process.argv[1], 'link', 'set', 'lo', 'up'], { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' });
|
|
11
|
+
if (ip.exitCode !== 0) process.exit(ip.exitCode || 1);
|
|
12
|
+
const command = Bun.spawn(process.argv.slice(2), { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' });
|
|
13
|
+
process.exit(await command.exited);`;
|
|
14
|
+
|
|
15
|
+
function killGroup(proc) {
|
|
16
|
+
try { process.kill(-proc.pid, 'SIGKILL'); }
|
|
17
|
+
catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function runCommand(commands, paths, kind, argv, cwd, env, limits, state, spawnArgv = argv) {
|
|
21
|
+
const started = performance.now();
|
|
22
|
+
const step = { kind, argv, spawnArgv, exitCode: null, durationMs: 0, logPath: `${paths.evidence}/${crypto.randomUUID()}-${kind}.log`, timedOut: false, logBytes: 0, omittedBytes: 0, logIncomplete: false };
|
|
23
|
+
step.receiptPath = `${step.logPath.slice(0, -4)}.json`;
|
|
24
|
+
commands.push(step);
|
|
25
|
+
const fd = openSync(step.logPath, 'wx', 0o600);
|
|
26
|
+
let proc;
|
|
27
|
+
let timer;
|
|
28
|
+
let graceTimer;
|
|
29
|
+
let logging = true;
|
|
30
|
+
const readers = [];
|
|
31
|
+
function log(chunk) {
|
|
32
|
+
if (!logging) return;
|
|
33
|
+
const kept = Math.min(chunk.length, limits.logBytes - step.logBytes);
|
|
34
|
+
if (kept) writeSync(fd, chunk.subarray(0, kept));
|
|
35
|
+
step.logBytes += kept;
|
|
36
|
+
step.omittedBytes += chunk.length - kept;
|
|
37
|
+
}
|
|
38
|
+
async function drain(reader) {
|
|
39
|
+
try {
|
|
40
|
+
while (true) {
|
|
41
|
+
const { done, value } = await reader.read();
|
|
42
|
+
if (done) return;
|
|
43
|
+
log(value);
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (logging) { step.error = error.message; killGroup(proc); }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
proc = Bun.spawn(spawnArgv, { cwd, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', detached: true });
|
|
51
|
+
state.active = proc;
|
|
52
|
+
readers.push(proc.stdout.getReader(), proc.stderr.getReader());
|
|
53
|
+
const drained = Promise.all(readers.map(drain));
|
|
54
|
+
const exited = proc.exited.then((code) => {
|
|
55
|
+
if (logging) step.exitCode = code;
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
killGroup(proc);
|
|
58
|
+
});
|
|
59
|
+
const timeout = new Promise((resolve) => {
|
|
60
|
+
timer = setTimeout(() => { step.timedOut = true; killGroup(proc); resolve(); }, limits.timeoutMs);
|
|
61
|
+
});
|
|
62
|
+
await Promise.race([exited, timeout]);
|
|
63
|
+
const complete = await Promise.race([
|
|
64
|
+
Promise.all([exited, drained]).then(() => true),
|
|
65
|
+
new Promise((resolve) => { graceTimer = setTimeout(() => resolve(false), drainGraceMs); }),
|
|
66
|
+
]);
|
|
67
|
+
step.logIncomplete = !complete;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
step.error = error.message;
|
|
70
|
+
log(new TextEncoder().encode(error.message));
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
clearTimeout(graceTimer);
|
|
74
|
+
logging = false;
|
|
75
|
+
if (proc) killGroup(proc);
|
|
76
|
+
// Cancel without another EOF wait. Escaped writers may retain the other pipe end forever.
|
|
77
|
+
for (const reader of readers) reader.cancel().then(() => reader.releaseLock()).catch(() => {});
|
|
78
|
+
state.active = null;
|
|
79
|
+
closeSync(fd);
|
|
80
|
+
step.durationMs = Math.round(performance.now() - started);
|
|
81
|
+
writeFileSync(step.receiptPath, `${JSON.stringify(step, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
82
|
+
}
|
|
83
|
+
return step;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function executeDeclared(repo, revision, paths, criteria, manager, pkg, limits) {
|
|
87
|
+
const pending = groups.filter(([id]) => criteria.find((item) => item.id === id).reason === 'not run');
|
|
88
|
+
const commands = [];
|
|
89
|
+
const result = { commands, network: 'not isolated', descendants: 'not contained' };
|
|
90
|
+
if (!pending.length) return result;
|
|
91
|
+
mkdirSync(paths.evidence, { recursive: true, mode: 0o700 });
|
|
92
|
+
let scratch;
|
|
93
|
+
const state = { active: null, signal: null };
|
|
94
|
+
const interrupt = (signal) => { state.signal = signal; if (state.active) killGroup(state.active); };
|
|
95
|
+
const sigint = () => interrupt('SIGINT');
|
|
96
|
+
const sigterm = () => interrupt('SIGTERM');
|
|
97
|
+
process.on('SIGINT', sigint);
|
|
98
|
+
process.on('SIGTERM', sigterm);
|
|
99
|
+
try {
|
|
100
|
+
const home = realpathSync(process.env.HOME || '/root');
|
|
101
|
+
let temp = realpathSync(process.env.TMPDIR || '/tmp');
|
|
102
|
+
if (temp === home || temp.startsWith(`${home}/`)) temp = realpathSync('/tmp');
|
|
103
|
+
if (temp === home || temp.startsWith(`${home}/`)) throw new Error('no scratch directory outside HOME; set TMPDIR to an owned temp directory');
|
|
104
|
+
scratch = mkdtempSync(`${temp}/axstack-readiness-`);
|
|
105
|
+
chmodSync(scratch, 0o700);
|
|
106
|
+
if (lstatSync(scratch).uid !== process.getuid()) throw new Error('scratch directory is not owned');
|
|
107
|
+
result.scratch = scratch;
|
|
108
|
+
const env = { PATH: process.env.PATH || '/usr/bin:/bin', LANG: process.env.LANG || 'C.UTF-8', CI: '1', HOME: scratch, TMPDIR: scratch };
|
|
109
|
+
const cwd = `${scratch}/repo`;
|
|
110
|
+
const unshare = Bun.which('unshare');
|
|
111
|
+
const ip = Bun.which('ip');
|
|
112
|
+
const pidFlags = ['--pid', '--fork', '--kill-child'];
|
|
113
|
+
const contained = (argv) => [unshare, '-r', ...pidFlags, ...argv];
|
|
114
|
+
const isolated = (argv) => [unshare, '-rn', ...(result.descendants === 'contained' ? pidFlags : []), process.execPath, '-e', networkWrapper, ip, ...argv];
|
|
115
|
+
const run = async (kind, argv, directory = scratch, spawnArgv) => {
|
|
116
|
+
if (state.signal) throw new Error(`interrupted by ${state.signal}`);
|
|
117
|
+
const step = await runCommand(commands, paths, kind, argv, directory, env, limits, state, spawnArgv ?? (result.descendants === 'contained' ? contained(argv) : argv));
|
|
118
|
+
if (state.signal) throw new Error(`interrupted by ${state.signal}; see ${step.logPath}`);
|
|
119
|
+
return step;
|
|
120
|
+
};
|
|
121
|
+
if (unshare) {
|
|
122
|
+
const probe = await run('descendant-probe', contained(['/bin/true']));
|
|
123
|
+
if (probe.exitCode === 0 && !probe.timedOut && !probe.error) result.descendants = 'contained';
|
|
124
|
+
}
|
|
125
|
+
if (unshare && ip) {
|
|
126
|
+
const argv = isolated(['/bin/true']);
|
|
127
|
+
const probe = await run('network-probe', argv, scratch, argv);
|
|
128
|
+
if (probe.exitCode === 0 && !probe.timedOut && !probe.error) result.network = 'isolated';
|
|
129
|
+
}
|
|
130
|
+
const git = ['git', '--no-optional-locks', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false'];
|
|
131
|
+
const clone = await run('clone', [...git, 'clone', '--no-hardlinks', '--no-checkout', '--', realpathSync(repo), cwd], scratch);
|
|
132
|
+
if (clone.exitCode !== 0 || clone.timedOut) throw new Error(`clone failed; see ${clone.logPath}`);
|
|
133
|
+
const checkout = await run('checkout', [...git, '-C', cwd, 'checkout', '--detach', revision], scratch);
|
|
134
|
+
if (checkout.exitCode !== 0 || checkout.timedOut) throw new Error(`checkout failed; see ${checkout.logPath}`);
|
|
135
|
+
const install = await run('install', [manager.name, ...installs[manager.name]], cwd);
|
|
136
|
+
if (install.exitCode !== 0 || install.timedOut) {
|
|
137
|
+
for (const [id] of pending) Object.assign(criteria.find((item) => item.id === id), { reason: 'install failed', evidence: [install.logPath] });
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
for (const [id, names] of pending) {
|
|
141
|
+
const steps = [];
|
|
142
|
+
for (const name of names.filter((name) => typeof pkg.scripts?.[name] === 'string' && pkg.scripts[name].trim())) {
|
|
143
|
+
const argv = [manager.name, ...(manager.name === 'pnpm' ? ['--config.enable-pre-post-scripts=false'] : []), 'run', name];
|
|
144
|
+
steps.push(await run('script', argv, cwd, result.network === 'isolated' ? isolated(argv) : undefined));
|
|
145
|
+
}
|
|
146
|
+
const passed = steps.every((step) => step.exitCode === 0 && !step.timedOut && !step.error);
|
|
147
|
+
Object.assign(criteria.find((item) => item.id === id), { status: passed ? 'pass' : 'fail', reason: steps.some((step) => step.timedOut) ? 'timeout' : passed ? '' : 'command failed', evidence: steps.map((step) => step.logPath) });
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
result.error = error.message;
|
|
151
|
+
for (const [id] of pending) Object.assign(criteria.find((item) => item.id === id), { status: 'unknown', reason: 'execution unavailable', evidence: [error.message] });
|
|
152
|
+
} finally {
|
|
153
|
+
process.off('SIGINT', sigint);
|
|
154
|
+
process.off('SIGTERM', sigterm);
|
|
155
|
+
if (scratch) {
|
|
156
|
+
try { rmSync(scratch, { recursive: true }); }
|
|
157
|
+
catch (error) { result.error = `scratch removal failed: ${scratch}: ${error.message}`; }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|