@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +4 -0
  4. package/README.md +200 -0
  5. package/dist/cli.js +464 -0
  6. package/dist/commands/__tests__/report.test.js +202 -0
  7. package/dist/commands/compare.js +168 -0
  8. package/dist/commands/doctor.js +124 -0
  9. package/dist/commands/follow.js +251 -0
  10. package/dist/commands/gc.js +161 -0
  11. package/dist/commands/guards-only.js +89 -0
  12. package/dist/commands/metrics.js +441 -0
  13. package/dist/commands/orchestrate.js +800 -0
  14. package/dist/commands/paths.js +31 -0
  15. package/dist/commands/preflight.js +152 -0
  16. package/dist/commands/report.js +478 -0
  17. package/dist/commands/resume.js +149 -0
  18. package/dist/commands/run.js +538 -0
  19. package/dist/commands/status.js +189 -0
  20. package/dist/commands/summarize.js +220 -0
  21. package/dist/commands/version.js +82 -0
  22. package/dist/commands/wait.js +170 -0
  23. package/dist/config/__tests__/presets.test.js +104 -0
  24. package/dist/config/load.js +66 -0
  25. package/dist/config/schema.js +160 -0
  26. package/dist/context/__tests__/artifact.test.js +130 -0
  27. package/dist/context/__tests__/pack.test.js +191 -0
  28. package/dist/context/artifact.js +67 -0
  29. package/dist/context/index.js +2 -0
  30. package/dist/context/pack.js +273 -0
  31. package/dist/diagnosis/analyzer.js +678 -0
  32. package/dist/diagnosis/formatter.js +136 -0
  33. package/dist/diagnosis/index.js +6 -0
  34. package/dist/diagnosis/types.js +7 -0
  35. package/dist/env/__tests__/fingerprint.test.js +116 -0
  36. package/dist/env/fingerprint.js +111 -0
  37. package/dist/orchestrator/__tests__/policy.test.js +185 -0
  38. package/dist/orchestrator/__tests__/schema-version.test.js +65 -0
  39. package/dist/orchestrator/artifacts.js +405 -0
  40. package/dist/orchestrator/state-machine.js +646 -0
  41. package/dist/orchestrator/types.js +88 -0
  42. package/dist/ownership/normalize.js +45 -0
  43. package/dist/repo/context.js +90 -0
  44. package/dist/repo/git.js +13 -0
  45. package/dist/repo/worktree.js +239 -0
  46. package/dist/store/run-store.js +107 -0
  47. package/dist/store/run-utils.js +69 -0
  48. package/dist/store/runs-root.js +126 -0
  49. package/dist/supervisor/__tests__/evidence-gate.test.js +111 -0
  50. package/dist/supervisor/__tests__/ownership.test.js +103 -0
  51. package/dist/supervisor/__tests__/state-machine.test.js +290 -0
  52. package/dist/supervisor/collision.js +240 -0
  53. package/dist/supervisor/evidence-gate.js +98 -0
  54. package/dist/supervisor/planner.js +18 -0
  55. package/dist/supervisor/runner.js +1562 -0
  56. package/dist/supervisor/scope-guard.js +55 -0
  57. package/dist/supervisor/state-machine.js +121 -0
  58. package/dist/supervisor/verification-policy.js +64 -0
  59. package/dist/tasks/task-metadata.js +72 -0
  60. package/dist/types/schemas.js +1 -0
  61. package/dist/verification/engine.js +49 -0
  62. package/dist/workers/__tests__/claude.test.js +88 -0
  63. package/dist/workers/__tests__/codex.test.js +81 -0
  64. package/dist/workers/claude.js +119 -0
  65. package/dist/workers/codex.js +162 -0
  66. package/dist/workers/json.js +22 -0
  67. package/dist/workers/mock.js +193 -0
  68. package/dist/workers/prompts.js +98 -0
  69. package/dist/workers/schemas.js +39 -0
  70. package/package.json +47 -0
  71. package/templates/prompts/implementer.md +70 -0
  72. package/templates/prompts/planner.md +62 -0
  73. 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,6 @@
1
+ /**
2
+ * Diagnosis module for auto-diagnosing run stop reasons.
3
+ */
4
+ export * from './types.js';
5
+ export * from './analyzer.js';
6
+ export * from './formatter.js';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Diagnosis types for auto-diagnosing run stop reasons.
3
+ *
4
+ * The diagnosis system analyzes timeline events, state, and logs to determine
5
+ * why a run stopped and what action to take next.
6
+ */
7
+ export {};
@@ -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
+ });