@weldr/runr 0.3.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/CHANGELOG.md +216 -0
- package/LICENSE +190 -0
- package/NOTICE +4 -0
- package/README.md +200 -0
- package/dist/cli.js +464 -0
- package/dist/commands/__tests__/report.test.js +202 -0
- package/dist/commands/compare.js +168 -0
- package/dist/commands/doctor.js +124 -0
- package/dist/commands/follow.js +251 -0
- package/dist/commands/gc.js +161 -0
- package/dist/commands/guards-only.js +89 -0
- package/dist/commands/metrics.js +441 -0
- package/dist/commands/orchestrate.js +800 -0
- package/dist/commands/paths.js +31 -0
- package/dist/commands/preflight.js +152 -0
- package/dist/commands/report.js +478 -0
- package/dist/commands/resume.js +149 -0
- package/dist/commands/run.js +538 -0
- package/dist/commands/status.js +189 -0
- package/dist/commands/summarize.js +220 -0
- package/dist/commands/version.js +82 -0
- package/dist/commands/wait.js +170 -0
- package/dist/config/__tests__/presets.test.js +104 -0
- package/dist/config/load.js +66 -0
- package/dist/config/schema.js +160 -0
- package/dist/context/__tests__/artifact.test.js +130 -0
- package/dist/context/__tests__/pack.test.js +191 -0
- package/dist/context/artifact.js +67 -0
- package/dist/context/index.js +2 -0
- package/dist/context/pack.js +273 -0
- package/dist/diagnosis/analyzer.js +678 -0
- package/dist/diagnosis/formatter.js +136 -0
- package/dist/diagnosis/index.js +6 -0
- package/dist/diagnosis/types.js +7 -0
- package/dist/env/__tests__/fingerprint.test.js +116 -0
- package/dist/env/fingerprint.js +111 -0
- package/dist/orchestrator/__tests__/policy.test.js +185 -0
- package/dist/orchestrator/__tests__/schema-version.test.js +65 -0
- package/dist/orchestrator/artifacts.js +405 -0
- package/dist/orchestrator/state-machine.js +646 -0
- package/dist/orchestrator/types.js +88 -0
- package/dist/ownership/normalize.js +45 -0
- package/dist/repo/context.js +90 -0
- package/dist/repo/git.js +13 -0
- package/dist/repo/worktree.js +239 -0
- package/dist/store/run-store.js +107 -0
- package/dist/store/run-utils.js +69 -0
- package/dist/store/runs-root.js +126 -0
- package/dist/supervisor/__tests__/evidence-gate.test.js +111 -0
- package/dist/supervisor/__tests__/ownership.test.js +103 -0
- package/dist/supervisor/__tests__/state-machine.test.js +290 -0
- package/dist/supervisor/collision.js +240 -0
- package/dist/supervisor/evidence-gate.js +98 -0
- package/dist/supervisor/planner.js +18 -0
- package/dist/supervisor/runner.js +1562 -0
- package/dist/supervisor/scope-guard.js +55 -0
- package/dist/supervisor/state-machine.js +121 -0
- package/dist/supervisor/verification-policy.js +64 -0
- package/dist/tasks/task-metadata.js +72 -0
- package/dist/types/schemas.js +1 -0
- package/dist/verification/engine.js +49 -0
- package/dist/workers/__tests__/claude.test.js +88 -0
- package/dist/workers/__tests__/codex.test.js +81 -0
- package/dist/workers/claude.js +119 -0
- package/dist/workers/codex.js +162 -0
- package/dist/workers/json.js +22 -0
- package/dist/workers/mock.js +193 -0
- package/dist/workers/prompts.js +98 -0
- package/dist/workers/schemas.js +39 -0
- package/package.json +47 -0
- package/templates/prompts/implementer.md +70 -0
- package/templates/prompts/planner.md +62 -0
- package/templates/prompts/reviewer.md +77 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatter for human-readable diagnosis output.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Human-readable descriptions for each diagnosis category.
|
|
6
|
+
*/
|
|
7
|
+
const categoryDescriptions = {
|
|
8
|
+
auth_expired: 'Worker authentication expired or invalid.',
|
|
9
|
+
verification_cwd_mismatch: 'Verification commands ran in the wrong directory.',
|
|
10
|
+
scope_violation: 'Files were modified outside the allowed scope.',
|
|
11
|
+
lockfile_restricted: 'Lockfile was modified but dependency changes are not allowed.',
|
|
12
|
+
verification_failure: 'Tests, linting, or type checks failed.',
|
|
13
|
+
worker_parse_failure: 'Worker returned malformed or unparseable response.',
|
|
14
|
+
stall_timeout: 'No progress detected for too long.',
|
|
15
|
+
max_ticks_reached: 'Reached maximum phase transitions before completion.',
|
|
16
|
+
time_budget_exceeded: 'Ran out of allocated time.',
|
|
17
|
+
guard_violation_dirty: 'Working directory has uncommitted changes.',
|
|
18
|
+
ownership_violation: 'Task modified files outside its declared owns: paths.',
|
|
19
|
+
unknown: 'Could not determine specific cause.'
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Format diagnosis as human-readable markdown.
|
|
23
|
+
*/
|
|
24
|
+
export function formatStopMarkdown(diagnosis) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
// Header
|
|
27
|
+
lines.push('# Stop Diagnosis');
|
|
28
|
+
lines.push('');
|
|
29
|
+
// What happened
|
|
30
|
+
lines.push('## What Happened');
|
|
31
|
+
lines.push('');
|
|
32
|
+
lines.push(`- **Run ID**: ${diagnosis.run_id}`);
|
|
33
|
+
lines.push(`- **Outcome**: ${diagnosis.outcome}`);
|
|
34
|
+
lines.push(`- **Stop Reason**: ${diagnosis.stop_reason ?? 'N/A'}`);
|
|
35
|
+
lines.push('');
|
|
36
|
+
// Probable cause
|
|
37
|
+
lines.push('## Probable Cause');
|
|
38
|
+
lines.push('');
|
|
39
|
+
lines.push(`**${formatCategory(diagnosis.primary_diagnosis)}** (${Math.round(diagnosis.confidence * 100)}% confidence)`);
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push(categoryDescriptions[diagnosis.primary_diagnosis]);
|
|
42
|
+
lines.push('');
|
|
43
|
+
// Evidence
|
|
44
|
+
if (diagnosis.signals.length > 0) {
|
|
45
|
+
lines.push('## Evidence');
|
|
46
|
+
lines.push('');
|
|
47
|
+
for (const signal of diagnosis.signals.slice(0, 5)) {
|
|
48
|
+
const snippet = signal.snippet ? `: ${signal.snippet}` : '';
|
|
49
|
+
lines.push(`- **${signal.source}** → ${signal.pattern}${snippet}`);
|
|
50
|
+
}
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
// Next actions
|
|
54
|
+
if (diagnosis.next_actions.length > 0) {
|
|
55
|
+
lines.push('## Do This Next');
|
|
56
|
+
lines.push('');
|
|
57
|
+
for (let i = 0; i < Math.min(diagnosis.next_actions.length, 4); i++) {
|
|
58
|
+
const action = diagnosis.next_actions[i];
|
|
59
|
+
lines.push(`### ${i + 1}. ${action.title}`);
|
|
60
|
+
lines.push('');
|
|
61
|
+
if (action.command) {
|
|
62
|
+
lines.push('```bash');
|
|
63
|
+
lines.push(action.command);
|
|
64
|
+
lines.push('```');
|
|
65
|
+
lines.push('');
|
|
66
|
+
}
|
|
67
|
+
lines.push(`*${action.why}*`);
|
|
68
|
+
lines.push('');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Escalation
|
|
72
|
+
lines.push('## If It Repeats');
|
|
73
|
+
lines.push('');
|
|
74
|
+
lines.push(getEscalationAdvice(diagnosis.primary_diagnosis));
|
|
75
|
+
lines.push('');
|
|
76
|
+
// Related artifacts
|
|
77
|
+
if (Object.keys(diagnosis.related_artifacts).length > 0) {
|
|
78
|
+
lines.push('## Related Artifacts');
|
|
79
|
+
lines.push('');
|
|
80
|
+
if (diagnosis.related_artifacts.report) {
|
|
81
|
+
lines.push(`- **Report**: \`${diagnosis.related_artifacts.report}\``);
|
|
82
|
+
}
|
|
83
|
+
if (diagnosis.related_artifacts.timeline) {
|
|
84
|
+
lines.push(`- **Timeline**: \`${diagnosis.related_artifacts.timeline}\``);
|
|
85
|
+
}
|
|
86
|
+
if (diagnosis.related_artifacts.verify_logs) {
|
|
87
|
+
lines.push(`- **Verify Logs**: \`${diagnosis.related_artifacts.verify_logs}\``);
|
|
88
|
+
}
|
|
89
|
+
if (diagnosis.related_artifacts.worker_output) {
|
|
90
|
+
lines.push(`- **Worker Output**: \`${diagnosis.related_artifacts.worker_output}\``);
|
|
91
|
+
}
|
|
92
|
+
lines.push('');
|
|
93
|
+
}
|
|
94
|
+
// Footer
|
|
95
|
+
lines.push('---');
|
|
96
|
+
lines.push(`*Diagnosed at ${diagnosis.diagnosed_at}*`);
|
|
97
|
+
return lines.join('\n');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Format category as human-readable title.
|
|
101
|
+
*/
|
|
102
|
+
function formatCategory(category) {
|
|
103
|
+
return category
|
|
104
|
+
.split('_')
|
|
105
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
106
|
+
.join(' ');
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Get escalation advice for repeated failures.
|
|
110
|
+
*/
|
|
111
|
+
function getEscalationAdvice(category) {
|
|
112
|
+
switch (category) {
|
|
113
|
+
case 'auth_expired':
|
|
114
|
+
return 'Check OAuth token expiry settings. Consider re-authenticating before long runs.';
|
|
115
|
+
case 'verification_cwd_mismatch':
|
|
116
|
+
return 'Review your agent.config.json verification section. The cwd must match where package.json lives.';
|
|
117
|
+
case 'scope_violation':
|
|
118
|
+
return 'Consider if the task scope is too narrow. You may need to expand allowlist or break into smaller tasks.';
|
|
119
|
+
case 'lockfile_restricted':
|
|
120
|
+
return 'If the task genuinely needs new dependencies, use --allow-deps. Otherwise, reword the task.';
|
|
121
|
+
case 'verification_failure':
|
|
122
|
+
return 'The implementation may have fundamental issues. Review the test output carefully and consider adjusting requirements.';
|
|
123
|
+
case 'worker_parse_failure':
|
|
124
|
+
return 'This may indicate an API issue. Try with a different worker or simpler task prompts.';
|
|
125
|
+
case 'stall_timeout':
|
|
126
|
+
return 'Persistent stalls may indicate infrastructure issues. Check network, API quotas, and worker health.';
|
|
127
|
+
case 'max_ticks_reached':
|
|
128
|
+
return 'If runs consistently hit tick limits, the task may be too complex. Consider breaking into smaller milestones.';
|
|
129
|
+
case 'time_budget_exceeded':
|
|
130
|
+
return 'For complex tasks, allocate more time upfront: --time 120 or higher.';
|
|
131
|
+
case 'guard_violation_dirty':
|
|
132
|
+
return 'Always use --worktree for runs on repos with active development.';
|
|
133
|
+
default:
|
|
134
|
+
return 'Review the timeline and logs carefully. Open an issue if the problem persists.';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { compareFingerprints } from '../fingerprint.js';
|
|
3
|
+
function makeFingerprint(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
node_version: 'v20.0.0',
|
|
6
|
+
package_manager: 'npm',
|
|
7
|
+
lockfile_hash: 'abc123def456',
|
|
8
|
+
worker_versions: {
|
|
9
|
+
codex: 'codex-cli 0.70.0',
|
|
10
|
+
claude: '2.0.50 (Claude Code)'
|
|
11
|
+
},
|
|
12
|
+
created_at: '2025-01-01T00:00:00.000Z',
|
|
13
|
+
...overrides
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
describe('compareFingerprints', () => {
|
|
17
|
+
it('returns empty array when fingerprints match', () => {
|
|
18
|
+
const original = makeFingerprint();
|
|
19
|
+
const current = makeFingerprint();
|
|
20
|
+
const diffs = compareFingerprints(original, current);
|
|
21
|
+
expect(diffs).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
it('detects node version change', () => {
|
|
24
|
+
const original = makeFingerprint({ node_version: 'v20.0.0' });
|
|
25
|
+
const current = makeFingerprint({ node_version: 'v22.0.0' });
|
|
26
|
+
const diffs = compareFingerprints(original, current);
|
|
27
|
+
expect(diffs).toHaveLength(1);
|
|
28
|
+
expect(diffs[0]).toEqual({
|
|
29
|
+
field: 'node_version',
|
|
30
|
+
original: 'v20.0.0',
|
|
31
|
+
current: 'v22.0.0'
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
it('detects package manager change', () => {
|
|
35
|
+
const original = makeFingerprint({ package_manager: 'npm' });
|
|
36
|
+
const current = makeFingerprint({ package_manager: 'pnpm' });
|
|
37
|
+
const diffs = compareFingerprints(original, current);
|
|
38
|
+
expect(diffs).toHaveLength(1);
|
|
39
|
+
expect(diffs[0].field).toBe('package_manager');
|
|
40
|
+
});
|
|
41
|
+
it('detects lockfile hash change', () => {
|
|
42
|
+
const original = makeFingerprint({ lockfile_hash: 'abc123' });
|
|
43
|
+
const current = makeFingerprint({ lockfile_hash: 'def456' });
|
|
44
|
+
const diffs = compareFingerprints(original, current);
|
|
45
|
+
expect(diffs).toHaveLength(1);
|
|
46
|
+
expect(diffs[0].field).toBe('lockfile_hash');
|
|
47
|
+
});
|
|
48
|
+
it('detects worker version change', () => {
|
|
49
|
+
const original = makeFingerprint({
|
|
50
|
+
worker_versions: { codex: '0.70.0', claude: '2.0.50' }
|
|
51
|
+
});
|
|
52
|
+
const current = makeFingerprint({
|
|
53
|
+
worker_versions: { codex: '0.80.0', claude: '2.0.50' }
|
|
54
|
+
});
|
|
55
|
+
const diffs = compareFingerprints(original, current);
|
|
56
|
+
expect(diffs).toHaveLength(1);
|
|
57
|
+
expect(diffs[0].field).toBe('worker:codex');
|
|
58
|
+
expect(diffs[0].original).toBe('0.70.0');
|
|
59
|
+
expect(diffs[0].current).toBe('0.80.0');
|
|
60
|
+
});
|
|
61
|
+
it('handles null lockfile gracefully', () => {
|
|
62
|
+
const original = makeFingerprint({ lockfile_hash: null });
|
|
63
|
+
const current = makeFingerprint({ lockfile_hash: 'abc123' });
|
|
64
|
+
const diffs = compareFingerprints(original, current);
|
|
65
|
+
expect(diffs).toHaveLength(1);
|
|
66
|
+
expect(diffs[0].field).toBe('lockfile_hash');
|
|
67
|
+
expect(diffs[0].original).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
it('handles null package manager gracefully', () => {
|
|
70
|
+
const original = makeFingerprint({ package_manager: null });
|
|
71
|
+
const current = makeFingerprint({ package_manager: null });
|
|
72
|
+
const diffs = compareFingerprints(original, current);
|
|
73
|
+
expect(diffs).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
it('handles null worker version gracefully', () => {
|
|
76
|
+
const original = makeFingerprint({
|
|
77
|
+
worker_versions: { codex: null, claude: '2.0.50' }
|
|
78
|
+
});
|
|
79
|
+
const current = makeFingerprint({
|
|
80
|
+
worker_versions: { codex: '0.80.0', claude: '2.0.50' }
|
|
81
|
+
});
|
|
82
|
+
const diffs = compareFingerprints(original, current);
|
|
83
|
+
expect(diffs).toHaveLength(1);
|
|
84
|
+
expect(diffs[0].original).toBeNull();
|
|
85
|
+
expect(diffs[0].current).toBe('0.80.0');
|
|
86
|
+
});
|
|
87
|
+
it('detects multiple changes at once', () => {
|
|
88
|
+
const original = makeFingerprint({
|
|
89
|
+
node_version: 'v20.0.0',
|
|
90
|
+
lockfile_hash: 'old-hash',
|
|
91
|
+
worker_versions: { codex: '0.70.0', claude: '2.0.50' }
|
|
92
|
+
});
|
|
93
|
+
const current = makeFingerprint({
|
|
94
|
+
node_version: 'v22.0.0',
|
|
95
|
+
lockfile_hash: 'new-hash',
|
|
96
|
+
worker_versions: { codex: '0.80.0', claude: '2.0.50' }
|
|
97
|
+
});
|
|
98
|
+
const diffs = compareFingerprints(original, current);
|
|
99
|
+
expect(diffs).toHaveLength(3);
|
|
100
|
+
expect(diffs.map((d) => d.field).sort()).toEqual([
|
|
101
|
+
'lockfile_hash',
|
|
102
|
+
'node_version',
|
|
103
|
+
'worker:codex'
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
it('ignores created_at timestamp differences', () => {
|
|
107
|
+
const original = makeFingerprint({
|
|
108
|
+
created_at: '2025-01-01T00:00:00.000Z'
|
|
109
|
+
});
|
|
110
|
+
const current = makeFingerprint({
|
|
111
|
+
created_at: '2025-06-15T12:30:00.000Z'
|
|
112
|
+
});
|
|
113
|
+
const diffs = compareFingerprints(original, current);
|
|
114
|
+
expect(diffs).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { execa } from 'execa';
|
|
5
|
+
async function getNodeVersion() {
|
|
6
|
+
return process.version;
|
|
7
|
+
}
|
|
8
|
+
async function detectPackageManager(repoPath) {
|
|
9
|
+
const lockFiles = [
|
|
10
|
+
{ file: 'pnpm-lock.yaml', pm: 'pnpm' },
|
|
11
|
+
{ file: 'yarn.lock', pm: 'yarn' },
|
|
12
|
+
{ file: 'package-lock.json', pm: 'npm' },
|
|
13
|
+
{ file: 'bun.lockb', pm: 'bun' }
|
|
14
|
+
];
|
|
15
|
+
for (const { file, pm } of lockFiles) {
|
|
16
|
+
if (fs.existsSync(path.join(repoPath, file))) {
|
|
17
|
+
return pm;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
async function getLockfileHash(repoPath) {
|
|
23
|
+
const lockFiles = [
|
|
24
|
+
'pnpm-lock.yaml',
|
|
25
|
+
'yarn.lock',
|
|
26
|
+
'package-lock.json',
|
|
27
|
+
'bun.lockb'
|
|
28
|
+
];
|
|
29
|
+
for (const file of lockFiles) {
|
|
30
|
+
const lockPath = path.join(repoPath, file);
|
|
31
|
+
if (fs.existsSync(lockPath)) {
|
|
32
|
+
const content = fs.readFileSync(lockPath);
|
|
33
|
+
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
|
34
|
+
return hash.slice(0, 16);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
async function getWorkerVersion(bin) {
|
|
40
|
+
try {
|
|
41
|
+
const result = await execa(bin, ['--version'], {
|
|
42
|
+
timeout: 5000,
|
|
43
|
+
reject: false
|
|
44
|
+
});
|
|
45
|
+
if (result.exitCode === 0) {
|
|
46
|
+
return result.stdout.trim().split('\n')[0];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Worker not found
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
async function getWorkerVersions(config) {
|
|
55
|
+
const versions = {};
|
|
56
|
+
for (const [name, workerConfig] of Object.entries(config.workers)) {
|
|
57
|
+
versions[name] = await getWorkerVersion(workerConfig.bin);
|
|
58
|
+
}
|
|
59
|
+
return versions;
|
|
60
|
+
}
|
|
61
|
+
export async function captureFingerprint(config, repoPath) {
|
|
62
|
+
const [nodeVersion, packageManager, lockfileHash, workerVersions] = await Promise.all([
|
|
63
|
+
getNodeVersion(),
|
|
64
|
+
detectPackageManager(repoPath),
|
|
65
|
+
getLockfileHash(repoPath),
|
|
66
|
+
getWorkerVersions(config)
|
|
67
|
+
]);
|
|
68
|
+
return {
|
|
69
|
+
node_version: nodeVersion,
|
|
70
|
+
package_manager: packageManager,
|
|
71
|
+
lockfile_hash: lockfileHash,
|
|
72
|
+
worker_versions: workerVersions,
|
|
73
|
+
created_at: new Date().toISOString()
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function compareFingerprints(original, current) {
|
|
77
|
+
const diffs = [];
|
|
78
|
+
if (original.node_version !== current.node_version) {
|
|
79
|
+
diffs.push({
|
|
80
|
+
field: 'node_version',
|
|
81
|
+
original: original.node_version,
|
|
82
|
+
current: current.node_version
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (original.package_manager !== current.package_manager) {
|
|
86
|
+
diffs.push({
|
|
87
|
+
field: 'package_manager',
|
|
88
|
+
original: original.package_manager,
|
|
89
|
+
current: current.package_manager
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (original.lockfile_hash !== current.lockfile_hash) {
|
|
93
|
+
diffs.push({
|
|
94
|
+
field: 'lockfile_hash',
|
|
95
|
+
original: original.lockfile_hash,
|
|
96
|
+
current: current.lockfile_hash
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
for (const workerName of Object.keys(original.worker_versions)) {
|
|
100
|
+
const origVersion = original.worker_versions[workerName];
|
|
101
|
+
const currVersion = current.worker_versions[workerName];
|
|
102
|
+
if (origVersion !== currVersion) {
|
|
103
|
+
diffs.push({
|
|
104
|
+
field: `worker:${workerName}`,
|
|
105
|
+
original: origVersion,
|
|
106
|
+
current: currVersion
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return diffs;
|
|
111
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy block tests for Phase 7B.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify:
|
|
5
|
+
* 1. Run creates state.policy correctly from CLI/config
|
|
6
|
+
* 2. Resume without overrides keeps policy unchanged (via getEffectivePolicy)
|
|
7
|
+
* 3. Legacy states (without policy block) are handled correctly
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { createInitialOrchestratorState, getEffectivePolicy } from '../state-machine.js';
|
|
11
|
+
// Sample config for testing
|
|
12
|
+
const sampleConfig = {
|
|
13
|
+
tracks: [
|
|
14
|
+
{
|
|
15
|
+
name: 'Track A',
|
|
16
|
+
steps: [{ task: 'tasks/a.md' }]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'Track B',
|
|
20
|
+
steps: [{ task: 'tasks/b.md' }]
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
};
|
|
24
|
+
describe('Policy Block', () => {
|
|
25
|
+
describe('createInitialOrchestratorState', () => {
|
|
26
|
+
it('creates state.policy correctly from CLI options', () => {
|
|
27
|
+
const state = createInitialOrchestratorState(sampleConfig, '/test/repo', {
|
|
28
|
+
timeBudgetMinutes: 60,
|
|
29
|
+
maxTicks: 25,
|
|
30
|
+
collisionPolicy: 'serialize',
|
|
31
|
+
fast: true,
|
|
32
|
+
autoResume: true,
|
|
33
|
+
parallel: 1,
|
|
34
|
+
ownershipRequired: true
|
|
35
|
+
});
|
|
36
|
+
// Policy block should exist
|
|
37
|
+
expect(state.policy).toBeDefined();
|
|
38
|
+
// Policy values should match options
|
|
39
|
+
expect(state.policy.time_budget_minutes).toBe(60);
|
|
40
|
+
expect(state.policy.max_ticks).toBe(25);
|
|
41
|
+
expect(state.policy.collision_policy).toBe('serialize');
|
|
42
|
+
expect(state.policy.fast).toBe(true);
|
|
43
|
+
expect(state.policy.auto_resume).toBe(true);
|
|
44
|
+
expect(state.policy.parallel).toBe(1);
|
|
45
|
+
expect(state.policy.ownership_required).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
it('sets default values for optional policy fields', () => {
|
|
48
|
+
const state = createInitialOrchestratorState(sampleConfig, '/test/repo', {
|
|
49
|
+
timeBudgetMinutes: 120,
|
|
50
|
+
maxTicks: 50,
|
|
51
|
+
collisionPolicy: 'force'
|
|
52
|
+
// fast, autoResume, parallel not provided
|
|
53
|
+
});
|
|
54
|
+
expect(state.policy).toBeDefined();
|
|
55
|
+
expect(state.policy.fast).toBe(false);
|
|
56
|
+
expect(state.policy.auto_resume).toBe(false);
|
|
57
|
+
expect(state.policy.parallel).toBe(2); // Default: track count
|
|
58
|
+
expect(state.policy.ownership_required).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
it('writes both policy block and legacy fields for backward compatibility', () => {
|
|
61
|
+
const state = createInitialOrchestratorState(sampleConfig, '/test/repo', {
|
|
62
|
+
timeBudgetMinutes: 90,
|
|
63
|
+
maxTicks: 30,
|
|
64
|
+
collisionPolicy: 'fail',
|
|
65
|
+
fast: true
|
|
66
|
+
});
|
|
67
|
+
// Policy block
|
|
68
|
+
expect(state.policy.time_budget_minutes).toBe(90);
|
|
69
|
+
expect(state.policy.max_ticks).toBe(30);
|
|
70
|
+
expect(state.policy.collision_policy).toBe('fail');
|
|
71
|
+
expect(state.policy.fast).toBe(true);
|
|
72
|
+
// Legacy fields (should match)
|
|
73
|
+
expect(state.time_budget_minutes).toBe(90);
|
|
74
|
+
expect(state.max_ticks).toBe(30);
|
|
75
|
+
expect(state.collision_policy).toBe('fail');
|
|
76
|
+
expect(state.fast).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('getEffectivePolicy', () => {
|
|
80
|
+
it('returns policy block when present', () => {
|
|
81
|
+
const state = createInitialOrchestratorState(sampleConfig, '/test/repo', {
|
|
82
|
+
timeBudgetMinutes: 45,
|
|
83
|
+
maxTicks: 15,
|
|
84
|
+
collisionPolicy: 'serialize',
|
|
85
|
+
fast: true,
|
|
86
|
+
autoResume: true
|
|
87
|
+
});
|
|
88
|
+
const policy = getEffectivePolicy(state);
|
|
89
|
+
expect(policy.time_budget_minutes).toBe(45);
|
|
90
|
+
expect(policy.max_ticks).toBe(15);
|
|
91
|
+
expect(policy.collision_policy).toBe('serialize');
|
|
92
|
+
expect(policy.fast).toBe(true);
|
|
93
|
+
expect(policy.auto_resume).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
it('falls back to legacy fields when policy block is missing (v0 state)', () => {
|
|
96
|
+
// Simulate a legacy v0 state without policy block
|
|
97
|
+
const legacyState = {
|
|
98
|
+
orchestrator_id: 'orch20240101120000',
|
|
99
|
+
repo_path: '/test/repo',
|
|
100
|
+
tracks: [
|
|
101
|
+
{
|
|
102
|
+
id: 'track-1',
|
|
103
|
+
name: 'Track A',
|
|
104
|
+
steps: [{ task_path: 'tasks/a.md' }],
|
|
105
|
+
current_step: 0,
|
|
106
|
+
status: 'pending'
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
active_runs: {},
|
|
110
|
+
file_claims: {},
|
|
111
|
+
status: 'running',
|
|
112
|
+
started_at: '2024-01-01T12:00:00Z',
|
|
113
|
+
// No policy block - legacy v0 state
|
|
114
|
+
collision_policy: 'force',
|
|
115
|
+
time_budget_minutes: 180,
|
|
116
|
+
max_ticks: 100,
|
|
117
|
+
fast: true
|
|
118
|
+
};
|
|
119
|
+
const policy = getEffectivePolicy(legacyState);
|
|
120
|
+
// Should extract from legacy fields
|
|
121
|
+
expect(policy.time_budget_minutes).toBe(180);
|
|
122
|
+
expect(policy.max_ticks).toBe(100);
|
|
123
|
+
expect(policy.collision_policy).toBe('force');
|
|
124
|
+
expect(policy.fast).toBe(true);
|
|
125
|
+
// Defaults for fields not in v0
|
|
126
|
+
expect(policy.auto_resume).toBe(false);
|
|
127
|
+
expect(policy.parallel).toBe(1); // track count
|
|
128
|
+
});
|
|
129
|
+
it('handles legacy state with fast=undefined', () => {
|
|
130
|
+
const legacyState = {
|
|
131
|
+
orchestrator_id: 'orch20240101120000',
|
|
132
|
+
repo_path: '/test/repo',
|
|
133
|
+
tracks: [],
|
|
134
|
+
active_runs: {},
|
|
135
|
+
file_claims: {},
|
|
136
|
+
status: 'running',
|
|
137
|
+
started_at: '2024-01-01T12:00:00Z',
|
|
138
|
+
collision_policy: 'serialize',
|
|
139
|
+
time_budget_minutes: 60,
|
|
140
|
+
max_ticks: 25
|
|
141
|
+
// fast is undefined (missing in v0)
|
|
142
|
+
};
|
|
143
|
+
const policy = getEffectivePolicy(legacyState);
|
|
144
|
+
expect(policy.fast).toBe(false); // Default when undefined
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe('Resume policy immutability', () => {
|
|
148
|
+
it('resume without overrides keeps policy unchanged', () => {
|
|
149
|
+
// Create initial state
|
|
150
|
+
const state = createInitialOrchestratorState(sampleConfig, '/test/repo', {
|
|
151
|
+
timeBudgetMinutes: 60,
|
|
152
|
+
maxTicks: 25,
|
|
153
|
+
collisionPolicy: 'serialize',
|
|
154
|
+
fast: true
|
|
155
|
+
});
|
|
156
|
+
// Simulate "resuming" by getting effective policy
|
|
157
|
+
const policyBeforeResume = getEffectivePolicy(state);
|
|
158
|
+
const policyAfterResume = getEffectivePolicy(state);
|
|
159
|
+
// Policy should be identical
|
|
160
|
+
expect(policyAfterResume).toEqual(policyBeforeResume);
|
|
161
|
+
});
|
|
162
|
+
it('policy values remain stable across multiple reads', () => {
|
|
163
|
+
const state = createInitialOrchestratorState(sampleConfig, '/test/repo', {
|
|
164
|
+
timeBudgetMinutes: 42,
|
|
165
|
+
maxTicks: 17,
|
|
166
|
+
collisionPolicy: 'force',
|
|
167
|
+
fast: false,
|
|
168
|
+
autoResume: true
|
|
169
|
+
});
|
|
170
|
+
// Read policy multiple times
|
|
171
|
+
const policy1 = getEffectivePolicy(state);
|
|
172
|
+
const policy2 = getEffectivePolicy(state);
|
|
173
|
+
const policy3 = getEffectivePolicy(state);
|
|
174
|
+
// All reads should return identical values
|
|
175
|
+
expect(policy1).toEqual(policy2);
|
|
176
|
+
expect(policy2).toEqual(policy3);
|
|
177
|
+
// And match original options
|
|
178
|
+
expect(policy1.time_budget_minutes).toBe(42);
|
|
179
|
+
expect(policy1.max_ticks).toBe(17);
|
|
180
|
+
expect(policy1.collision_policy).toBe('force');
|
|
181
|
+
expect(policy1.fast).toBe(false);
|
|
182
|
+
expect(policy1.auto_resume).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema version tests for Phase 7C.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify:
|
|
5
|
+
* 1. Artifacts include schema_version field
|
|
6
|
+
* 2. Schema version is the expected value
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { buildWaitResult, buildSummaryArtifact, buildStopArtifact } from '../artifacts.js';
|
|
10
|
+
import { createInitialOrchestratorState } from '../state-machine.js';
|
|
11
|
+
import { ORCHESTRATOR_ARTIFACT_SCHEMA_VERSION } from '../types.js';
|
|
12
|
+
const sampleConfig = {
|
|
13
|
+
tracks: [
|
|
14
|
+
{
|
|
15
|
+
name: 'Test Track',
|
|
16
|
+
steps: [{ task: 'tasks/test.md' }]
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
};
|
|
20
|
+
describe('Schema Versioning', () => {
|
|
21
|
+
describe('Orchestration Artifacts', () => {
|
|
22
|
+
it('buildWaitResult includes schema_version', () => {
|
|
23
|
+
const state = createInitialOrchestratorState(sampleConfig, '/test/repo', {
|
|
24
|
+
timeBudgetMinutes: 60,
|
|
25
|
+
maxTicks: 25,
|
|
26
|
+
collisionPolicy: 'serialize'
|
|
27
|
+
});
|
|
28
|
+
// Mark as complete for testing
|
|
29
|
+
state.status = 'complete';
|
|
30
|
+
state.ended_at = new Date().toISOString();
|
|
31
|
+
const result = buildWaitResult(state, '/test/repo');
|
|
32
|
+
expect(result.schema_version).toBe(ORCHESTRATOR_ARTIFACT_SCHEMA_VERSION);
|
|
33
|
+
expect(result.schema_version).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
it('buildSummaryArtifact includes schema_version', () => {
|
|
36
|
+
const state = createInitialOrchestratorState(sampleConfig, '/test/repo', {
|
|
37
|
+
timeBudgetMinutes: 60,
|
|
38
|
+
maxTicks: 25,
|
|
39
|
+
collisionPolicy: 'serialize'
|
|
40
|
+
});
|
|
41
|
+
state.status = 'complete';
|
|
42
|
+
state.ended_at = new Date().toISOString();
|
|
43
|
+
const summary = buildSummaryArtifact(state, '/test/repo');
|
|
44
|
+
expect(summary.schema_version).toBe(ORCHESTRATOR_ARTIFACT_SCHEMA_VERSION);
|
|
45
|
+
expect(summary.schema_version).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
it('buildStopArtifact includes schema_version', () => {
|
|
48
|
+
const state = createInitialOrchestratorState(sampleConfig, '/test/repo', {
|
|
49
|
+
timeBudgetMinutes: 60,
|
|
50
|
+
maxTicks: 25,
|
|
51
|
+
collisionPolicy: 'serialize'
|
|
52
|
+
});
|
|
53
|
+
state.status = 'stopped';
|
|
54
|
+
state.ended_at = new Date().toISOString();
|
|
55
|
+
const stopArtifact = buildStopArtifact(state, '/test/repo');
|
|
56
|
+
expect(stopArtifact.schema_version).toBe(ORCHESTRATOR_ARTIFACT_SCHEMA_VERSION);
|
|
57
|
+
expect(stopArtifact.schema_version).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('Schema version constant', () => {
|
|
61
|
+
it('ORCHESTRATOR_ARTIFACT_SCHEMA_VERSION is 1', () => {
|
|
62
|
+
expect(ORCHESTRATOR_ARTIFACT_SCHEMA_VERSION).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|