oxe-cc 0.9.3 → 1.0.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 (151) hide show
  1. package/README.md +1 -1
  2. package/bin/banner.txt +1 -1
  3. package/bin/lib/oxe-dashboard.cjs +9 -7
  4. package/bin/lib/oxe-operational.cjs +569 -4
  5. package/bin/oxe-cc.js +141 -57
  6. package/lib/runtime/compiler/graph-compiler.d.ts +83 -0
  7. package/lib/runtime/compiler/graph-compiler.js +135 -0
  8. package/lib/runtime/compiler/index.d.ts +1 -0
  9. package/lib/runtime/compiler/index.js +17 -0
  10. package/lib/runtime/context/context-pack-builder.d.ts +36 -0
  11. package/lib/runtime/context/context-pack-builder.js +136 -0
  12. package/lib/runtime/context/index.d.ts +1 -0
  13. package/lib/runtime/context/index.js +17 -0
  14. package/lib/runtime/delivery/branch-manager.d.ts +19 -0
  15. package/lib/runtime/delivery/branch-manager.js +78 -0
  16. package/lib/runtime/delivery/ci-checks.d.ts +34 -0
  17. package/lib/runtime/delivery/ci-checks.js +209 -0
  18. package/lib/runtime/delivery/index.d.ts +3 -0
  19. package/lib/runtime/delivery/index.js +19 -0
  20. package/lib/runtime/delivery/pr-manager.d.ts +30 -0
  21. package/lib/runtime/delivery/pr-manager.js +82 -0
  22. package/lib/runtime/events/bus.d.ts +9 -0
  23. package/lib/runtime/events/bus.js +63 -0
  24. package/lib/runtime/events/catalog.d.ts +3 -0
  25. package/lib/runtime/events/catalog.js +30 -0
  26. package/lib/runtime/events/envelope.d.ts +13 -0
  27. package/lib/runtime/events/envelope.js +2 -0
  28. package/lib/runtime/events/index.d.ts +3 -0
  29. package/lib/runtime/events/index.js +19 -0
  30. package/lib/runtime/evidence/evidence-store.d.ts +22 -0
  31. package/lib/runtime/evidence/evidence-store.js +106 -0
  32. package/lib/runtime/evidence/index.d.ts +1 -0
  33. package/lib/runtime/evidence/index.js +17 -0
  34. package/lib/runtime/gate/gate-manager.d.ts +39 -0
  35. package/lib/runtime/gate/gate-manager.js +104 -0
  36. package/lib/runtime/gate/index.d.ts +1 -0
  37. package/lib/runtime/gate/index.js +17 -0
  38. package/lib/runtime/index.d.ts +16 -0
  39. package/lib/runtime/index.js +40 -0
  40. package/lib/runtime/models/attempt.d.ts +12 -0
  41. package/lib/runtime/models/attempt.js +2 -0
  42. package/lib/runtime/models/evidence.d.ts +9 -0
  43. package/lib/runtime/models/evidence.js +2 -0
  44. package/lib/runtime/models/gate-decision.d.ts +10 -0
  45. package/lib/runtime/models/gate-decision.js +2 -0
  46. package/lib/runtime/models/index.d.ts +8 -0
  47. package/lib/runtime/models/index.js +24 -0
  48. package/lib/runtime/models/run.d.ts +13 -0
  49. package/lib/runtime/models/run.js +2 -0
  50. package/lib/runtime/models/session.d.ts +10 -0
  51. package/lib/runtime/models/session.js +2 -0
  52. package/lib/runtime/models/verification-result.d.ts +9 -0
  53. package/lib/runtime/models/verification-result.js +2 -0
  54. package/lib/runtime/models/work-item.d.ts +15 -0
  55. package/lib/runtime/models/work-item.js +2 -0
  56. package/lib/runtime/models/workspace.d.ts +25 -0
  57. package/lib/runtime/models/workspace.js +2 -0
  58. package/lib/runtime/plugins/index.d.ts +2 -0
  59. package/lib/runtime/plugins/index.js +18 -0
  60. package/lib/runtime/plugins/plugin-abi.d.ts +76 -0
  61. package/lib/runtime/plugins/plugin-abi.js +2 -0
  62. package/lib/runtime/plugins/plugin-registry.d.ts +21 -0
  63. package/lib/runtime/plugins/plugin-registry.js +114 -0
  64. package/lib/runtime/policy/index.d.ts +1 -0
  65. package/lib/runtime/policy/index.js +17 -0
  66. package/lib/runtime/policy/policy-engine.d.ts +40 -0
  67. package/lib/runtime/policy/policy-engine.js +80 -0
  68. package/lib/runtime/projection/index.d.ts +1 -0
  69. package/lib/runtime/projection/index.js +17 -0
  70. package/lib/runtime/projection/projection-engine.d.ts +11 -0
  71. package/lib/runtime/projection/projection-engine.js +218 -0
  72. package/lib/runtime/reducers/debug-reducer.d.ts +10 -0
  73. package/lib/runtime/reducers/debug-reducer.js +30 -0
  74. package/lib/runtime/reducers/index.d.ts +2 -0
  75. package/lib/runtime/reducers/index.js +18 -0
  76. package/lib/runtime/reducers/run-state-reducer.d.ts +20 -0
  77. package/lib/runtime/reducers/run-state-reducer.js +110 -0
  78. package/lib/runtime/scheduler/index.d.ts +1 -0
  79. package/lib/runtime/scheduler/index.js +17 -0
  80. package/lib/runtime/scheduler/multi-agent-coordinator.d.ts +34 -0
  81. package/lib/runtime/scheduler/multi-agent-coordinator.js +166 -0
  82. package/lib/runtime/scheduler/scheduler.d.ts +39 -0
  83. package/lib/runtime/scheduler/scheduler.js +196 -0
  84. package/lib/runtime/verification/index.d.ts +1 -0
  85. package/lib/runtime/verification/index.js +17 -0
  86. package/lib/runtime/verification/verification-compiler.d.ts +56 -0
  87. package/lib/runtime/verification/verification-compiler.js +147 -0
  88. package/lib/runtime/workspace/index.d.ts +5 -0
  89. package/lib/runtime/workspace/index.js +24 -0
  90. package/lib/runtime/workspace/strategies/ephemeral-container.d.ts +22 -0
  91. package/lib/runtime/workspace/strategies/ephemeral-container.js +109 -0
  92. package/lib/runtime/workspace/strategies/git-worktree.d.ts +12 -0
  93. package/lib/runtime/workspace/strategies/git-worktree.js +79 -0
  94. package/lib/runtime/workspace/strategies/inplace.d.ts +10 -0
  95. package/lib/runtime/workspace/strategies/inplace.js +37 -0
  96. package/lib/runtime/workspace/workspace-manager.d.ts +13 -0
  97. package/lib/runtime/workspace/workspace-manager.js +2 -0
  98. package/lib/sdk/index.cjs +24 -7
  99. package/lib/sdk/index.d.ts +17 -7
  100. package/package.json +9 -3
  101. package/packages/runtime/package.json +17 -0
  102. package/packages/runtime/src/compiler/graph-compiler.ts +245 -0
  103. package/packages/runtime/src/compiler/index.ts +1 -0
  104. package/packages/runtime/src/context/context-pack-builder.ts +193 -0
  105. package/packages/runtime/src/context/index.ts +1 -0
  106. package/packages/runtime/src/delivery/branch-manager.ts +84 -0
  107. package/packages/runtime/src/delivery/ci-checks.ts +252 -0
  108. package/packages/runtime/src/delivery/index.ts +3 -0
  109. package/packages/runtime/src/delivery/pr-manager.ts +112 -0
  110. package/packages/runtime/src/events/bus.ts +92 -0
  111. package/packages/runtime/src/events/catalog.ts +29 -0
  112. package/packages/runtime/src/events/envelope.ts +14 -0
  113. package/packages/runtime/src/events/index.ts +3 -0
  114. package/packages/runtime/src/evidence/evidence-store.ts +130 -0
  115. package/packages/runtime/src/evidence/index.ts +1 -0
  116. package/packages/runtime/src/gate/gate-manager.ts +137 -0
  117. package/packages/runtime/src/gate/index.ts +1 -0
  118. package/packages/runtime/src/index.ts +32 -0
  119. package/packages/runtime/src/models/attempt.ts +19 -0
  120. package/packages/runtime/src/models/evidence.ts +21 -0
  121. package/packages/runtime/src/models/gate-decision.ts +21 -0
  122. package/packages/runtime/src/models/index.ts +8 -0
  123. package/packages/runtime/src/models/run.ts +24 -0
  124. package/packages/runtime/src/models/session.ts +11 -0
  125. package/packages/runtime/src/models/verification-result.ts +10 -0
  126. package/packages/runtime/src/models/work-item.ts +25 -0
  127. package/packages/runtime/src/models/workspace.ts +28 -0
  128. package/packages/runtime/src/plugins/index.ts +2 -0
  129. package/packages/runtime/src/plugins/plugin-abi.ts +95 -0
  130. package/packages/runtime/src/plugins/plugin-registry.ts +119 -0
  131. package/packages/runtime/src/policy/index.ts +1 -0
  132. package/packages/runtime/src/policy/policy-engine.ts +113 -0
  133. package/packages/runtime/src/projection/index.ts +1 -0
  134. package/packages/runtime/src/projection/projection-engine.ts +249 -0
  135. package/packages/runtime/src/reducers/debug-reducer.ts +36 -0
  136. package/packages/runtime/src/reducers/index.ts +2 -0
  137. package/packages/runtime/src/reducers/run-state-reducer.ts +127 -0
  138. package/packages/runtime/src/scheduler/index.ts +1 -0
  139. package/packages/runtime/src/scheduler/multi-agent-coordinator.ts +231 -0
  140. package/packages/runtime/src/scheduler/scheduler.ts +281 -0
  141. package/packages/runtime/src/verification/index.ts +1 -0
  142. package/packages/runtime/src/verification/verification-compiler.ts +225 -0
  143. package/packages/runtime/src/workspace/index.ts +5 -0
  144. package/packages/runtime/src/workspace/strategies/ephemeral-container.ts +121 -0
  145. package/packages/runtime/src/workspace/strategies/git-worktree.ts +77 -0
  146. package/packages/runtime/src/workspace/strategies/inplace.ts +35 -0
  147. package/packages/runtime/src/workspace/workspace-manager.ts +15 -0
  148. package/packages/runtime/tsconfig.json +17 -0
  149. package/vscode-extension/oxe-agents-0.9.2.vsix +0 -0
  150. package/vscode-extension/oxe-agents-1.0.0.vsix +0 -0
  151. package/vscode-extension/package.json +1 -1
@@ -0,0 +1,281 @@
1
+ import { appendEvent } from '../events/bus';
2
+ import type { OxeEvent } from '../events/envelope';
3
+ import type { EventInput } from '../events/bus';
4
+ import type { ExecutionGraph, GraphNode } from '../compiler/graph-compiler';
5
+ import type { WorkspaceManager, WorkspaceRequest } from '../workspace/workspace-manager';
6
+ import type { WorkspaceLease } from '../models/workspace';
7
+
8
+ export interface TaskResult {
9
+ success: boolean;
10
+ failure_class: 'env' | 'policy' | 'test' | 'timeout' | null;
11
+ evidence: string[];
12
+ output: string;
13
+ }
14
+
15
+ export interface TaskExecutor {
16
+ execute(
17
+ node: GraphNode,
18
+ lease: WorkspaceLease,
19
+ runId: string,
20
+ attemptNumber: number
21
+ ): Promise<TaskResult>;
22
+ }
23
+
24
+ export interface SchedulerContext {
25
+ projectRoot: string;
26
+ sessionId: string | null;
27
+ runId: string;
28
+ executor: TaskExecutor;
29
+ workspaceManager: WorkspaceManager;
30
+ onEvent?: (event: OxeEvent) => void;
31
+ }
32
+
33
+ export interface RunResult {
34
+ run_id: string;
35
+ status: 'completed' | 'failed' | 'cancelled';
36
+ completed: string[];
37
+ failed: string[];
38
+ blocked: string[];
39
+ }
40
+
41
+ type NodeStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'blocked';
42
+
43
+ export class Scheduler {
44
+ private cancelled = false;
45
+ private paused = false;
46
+
47
+ async run(graph: ExecutionGraph, ctx: SchedulerContext): Promise<RunResult> {
48
+ this.cancelled = false;
49
+ this.paused = false;
50
+
51
+ const status = new Map<string, NodeStatus>();
52
+ for (const id of graph.nodes.keys()) status.set(id, 'pending');
53
+
54
+ const completed: string[] = [];
55
+ const failed: string[] = [];
56
+ const blocked: string[] = [];
57
+
58
+ this.emit(ctx, { type: 'RunStarted', payload: { run_id: ctx.runId } });
59
+
60
+ for (const wave of graph.waves) {
61
+ if (this.cancelled) break;
62
+ const waveFailed = await this.runWave(
63
+ wave.node_ids,
64
+ graph,
65
+ ctx,
66
+ status,
67
+ completed,
68
+ failed,
69
+ blocked
70
+ );
71
+ if (waveFailed) break;
72
+ }
73
+
74
+ // Any remaining pending nodes become blocked
75
+ for (const [id, s] of status) {
76
+ if (s === 'pending') {
77
+ status.set(id, 'blocked');
78
+ blocked.push(id);
79
+ this.emit(ctx, {
80
+ type: 'WorkItemBlocked',
81
+ work_item_id: id,
82
+ payload: { reason: 'upstream_wave_failed' },
83
+ });
84
+ }
85
+ }
86
+
87
+ const finalStatus: RunResult['status'] = this.cancelled
88
+ ? 'cancelled'
89
+ : failed.length > 0
90
+ ? 'failed'
91
+ : 'completed';
92
+
93
+ this.emit(ctx, {
94
+ type: 'RunCompleted',
95
+ payload: { run_id: ctx.runId, status: finalStatus },
96
+ });
97
+
98
+ return { run_id: ctx.runId, status: finalStatus, completed, failed, blocked };
99
+ }
100
+
101
+ private async runWave(
102
+ nodeIds: string[],
103
+ graph: ExecutionGraph,
104
+ ctx: SchedulerContext,
105
+ status: Map<string, NodeStatus>,
106
+ completed: string[],
107
+ failed: string[],
108
+ blocked: string[]
109
+ ): Promise<boolean> {
110
+ // Partition: eligible vs blocked-by-dep
111
+ const eligible: string[] = [];
112
+ const depsNotMet: string[] = [];
113
+
114
+ for (const id of nodeIds) {
115
+ const node = graph.nodes.get(id)!;
116
+ const depsMet = node.depends_on.every((dep) => status.get(dep) === 'completed');
117
+ if (depsMet) {
118
+ eligible.push(id);
119
+ } else {
120
+ depsNotMet.push(id);
121
+ }
122
+ }
123
+
124
+ // Nodes whose deps weren't met in this wave → blocked
125
+ for (const id of depsNotMet) {
126
+ status.set(id, 'blocked');
127
+ blocked.push(id);
128
+ this.emit(ctx, {
129
+ type: 'WorkItemBlocked',
130
+ work_item_id: id,
131
+ payload: { reason: 'dependency_not_met' },
132
+ });
133
+ }
134
+
135
+ // Separate read-only (no mutation_scope) from mutation nodes
136
+ const readOnly = eligible.filter((id) => {
137
+ const node = graph.nodes.get(id)!;
138
+ return node.mutation_scope.length === 0;
139
+ });
140
+ const mutations = eligible.filter((id) => !readOnly.includes(id));
141
+
142
+ // Run read-only nodes in parallel
143
+ if (readOnly.length > 0) {
144
+ await Promise.all(
145
+ readOnly.map((id) => this.runNode(id, graph, ctx, status, completed, failed))
146
+ );
147
+ }
148
+
149
+ // Run mutation nodes sequentially to avoid scope conflicts
150
+ for (const id of mutations) {
151
+ if (this.cancelled) break;
152
+ await this.runNode(id, graph, ctx, status, completed, failed);
153
+ }
154
+
155
+ return failed.length > 0;
156
+ }
157
+
158
+ private async runNode(
159
+ nodeId: string,
160
+ graph: ExecutionGraph,
161
+ ctx: SchedulerContext,
162
+ status: Map<string, NodeStatus>,
163
+ completed: string[],
164
+ failed: string[]
165
+ ): Promise<void> {
166
+ const node = graph.nodes.get(nodeId)!;
167
+ status.set(nodeId, 'running');
168
+ this.emit(ctx, {
169
+ type: 'WorkItemReady',
170
+ work_item_id: nodeId,
171
+ payload: { title: node.title, wave: node.wave },
172
+ });
173
+
174
+ let lease: WorkspaceLease | null = null;
175
+ let lastResult: TaskResult | null = null;
176
+ const maxAttempts = node.policy.max_retries + 1;
177
+
178
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
179
+ const attemptId = `${nodeId}-a${attempt}`;
180
+
181
+ this.emit(ctx, {
182
+ type: 'AttemptStarted',
183
+ work_item_id: nodeId,
184
+ attempt_id: attemptId,
185
+ payload: { attempt_number: attempt },
186
+ });
187
+
188
+ try {
189
+ const wsReq: WorkspaceRequest = {
190
+ work_item_id: nodeId,
191
+ attempt_number: attempt,
192
+ strategy: node.workspace_strategy,
193
+ mutation_scope: node.mutation_scope,
194
+ };
195
+ lease = await ctx.workspaceManager.allocate(wsReq);
196
+ this.emit(ctx, {
197
+ type: 'WorkspaceAllocated',
198
+ work_item_id: nodeId,
199
+ attempt_id: attemptId,
200
+ payload: { workspace_id: lease.workspace_id, strategy: lease.strategy },
201
+ });
202
+
203
+ lastResult = await ctx.executor.execute(node, lease, ctx.runId, attempt);
204
+
205
+ if (lastResult.success) {
206
+ this.emit(ctx, {
207
+ type: 'WorkItemCompleted',
208
+ work_item_id: nodeId,
209
+ attempt_id: attemptId,
210
+ payload: { attempt_number: attempt, evidence: lastResult.evidence },
211
+ });
212
+ status.set(nodeId, 'completed');
213
+ completed.push(nodeId);
214
+ return;
215
+ }
216
+
217
+ // Policy failures never retry
218
+ if (lastResult.failure_class === 'policy') break;
219
+
220
+ if (attempt < maxAttempts) {
221
+ this.emit(ctx, {
222
+ type: 'RetryScheduled',
223
+ work_item_id: nodeId,
224
+ payload: { next_attempt: attempt + 1, reason: lastResult.failure_class },
225
+ });
226
+ }
227
+ } catch (err) {
228
+ lastResult = {
229
+ success: false,
230
+ failure_class: 'env',
231
+ evidence: [],
232
+ output: String(err),
233
+ };
234
+ if (attempt < maxAttempts) {
235
+ this.emit(ctx, {
236
+ type: 'RetryScheduled',
237
+ work_item_id: nodeId,
238
+ payload: { next_attempt: attempt + 1, reason: 'env' },
239
+ });
240
+ }
241
+ } finally {
242
+ if (lease) {
243
+ await ctx.workspaceManager.dispose(lease.workspace_id).catch(() => {});
244
+ lease = null;
245
+ }
246
+ }
247
+ }
248
+
249
+ // All attempts exhausted
250
+ this.emit(ctx, {
251
+ type: 'WorkItemBlocked',
252
+ work_item_id: nodeId,
253
+ payload: { failure_class: lastResult?.failure_class ?? 'env', max_attempts: maxAttempts },
254
+ });
255
+ status.set(nodeId, 'failed');
256
+ failed.push(nodeId);
257
+ }
258
+
259
+ pause(): void {
260
+ this.paused = true;
261
+ }
262
+
263
+ resume(): void {
264
+ this.paused = false;
265
+ }
266
+
267
+ cancel(): void {
268
+ this.cancelled = true;
269
+ }
270
+
271
+ private emit(
272
+ ctx: SchedulerContext,
273
+ input: EventInput
274
+ ): void {
275
+ const event = appendEvent(ctx.projectRoot, ctx.sessionId, {
276
+ run_id: ctx.runId,
277
+ ...input,
278
+ });
279
+ ctx.onEvent?.(event);
280
+ }
281
+ }
@@ -0,0 +1 @@
1
+ export * from './verification-compiler';
@@ -0,0 +1,225 @@
1
+ import crypto from 'crypto';
2
+ import { spawnSync } from 'child_process';
3
+ import type { EvidenceType } from '../models/evidence';
4
+ import type { VerificationStatus } from '../models/verification-result';
5
+
6
+ export type CheckType =
7
+ | 'unit'
8
+ | 'integration'
9
+ | 'contract'
10
+ | 'smoke'
11
+ | 'policy'
12
+ | 'security'
13
+ | 'ux_snapshot'
14
+ | 'performance_baseline'
15
+ | 'custom';
16
+
17
+ export interface AcceptanceCheck {
18
+ id: string;
19
+ type: CheckType;
20
+ command: string | null;
21
+ evidence_type_expected: EvidenceType;
22
+ acceptance_ref: string | null;
23
+ description: string;
24
+ }
25
+
26
+ export interface AcceptanceCheckSuite {
27
+ checks: AcceptanceCheck[];
28
+ compiled_at: string;
29
+ spec_hash: string;
30
+ plan_hash: string;
31
+ }
32
+
33
+ export interface CheckResult {
34
+ check_id: string;
35
+ acceptance_ref: string | null;
36
+ status: VerificationStatus;
37
+ stdout: string;
38
+ stderr: string;
39
+ exit_code: number | null;
40
+ duration_ms: number;
41
+ error: string | null;
42
+ }
43
+
44
+ // Mirror of ParsedSpec/ParsedPlan (same as in graph-compiler to avoid circular deps)
45
+ interface Criterion {
46
+ id: string;
47
+ criterion: string;
48
+ howToVerify: string;
49
+ }
50
+
51
+ interface ParsedSpecLike {
52
+ objective: string | null;
53
+ criteria: Criterion[];
54
+ }
55
+
56
+ interface ParsedTaskLike {
57
+ id: string;
58
+ verifyCommand: string | null;
59
+ aceite: string[];
60
+ }
61
+
62
+ interface ParsedPlanLike {
63
+ tasks: ParsedTaskLike[];
64
+ }
65
+
66
+ function inferCheckType(howToVerify: string): CheckType {
67
+ const v = howToVerify.toLowerCase();
68
+ if (v.includes('npm test') || v.includes('jest') || v.includes('vitest') || v.includes('node --test')) return 'unit';
69
+ if (v.includes('postman') || v.includes('newman') || v.includes('integration')) return 'integration';
70
+ if (v.includes('smoke') || v.includes('curl')) return 'smoke';
71
+ if (v.includes('eslint') || v.includes('lint') || v.includes('oxe-policy')) return 'policy';
72
+ if (v.includes('security') || v.includes('audit') || v.includes('trivy')) return 'security';
73
+ return 'custom';
74
+ }
75
+
76
+ function inferEvidenceType(checkType: CheckType): EvidenceType {
77
+ switch (checkType) {
78
+ case 'unit': return 'junit_xml';
79
+ case 'integration': return 'api_output';
80
+ case 'security': return 'security_report';
81
+ case 'policy': return 'log';
82
+ default: return 'stdout';
83
+ }
84
+ }
85
+
86
+ export function compile(
87
+ spec: ParsedSpecLike,
88
+ plan: ParsedPlanLike
89
+ ): AcceptanceCheckSuite {
90
+ const checks: AcceptanceCheck[] = [];
91
+ const seenRefs = new Set<string>();
92
+
93
+ // Generate checks from spec criteria
94
+ for (const criterion of spec.criteria) {
95
+ // Find the verify command from the task that references this criterion
96
+ const task = plan.tasks.find((t) => t.aceite.includes(criterion.id));
97
+ const command = task?.verifyCommand ?? null;
98
+ const type = inferCheckType(criterion.howToVerify);
99
+
100
+ checks.push({
101
+ id: `check-${criterion.id.toLowerCase()}`,
102
+ type,
103
+ command: command ?? (criterion.howToVerify.startsWith('#') ? null : criterion.howToVerify),
104
+ evidence_type_expected: inferEvidenceType(type),
105
+ acceptance_ref: criterion.id,
106
+ description: criterion.criterion,
107
+ });
108
+ seenRefs.add(criterion.id);
109
+ }
110
+
111
+ // Add checks for task verify commands not already covered
112
+ for (const task of plan.tasks) {
113
+ if (!task.verifyCommand) continue;
114
+ const uncovered = task.aceite.filter((ref) => !seenRefs.has(ref));
115
+ if (uncovered.length === 0 && checks.some((c) => c.command === task.verifyCommand)) continue;
116
+
117
+ checks.push({
118
+ id: `check-task-${task.id.toLowerCase()}`,
119
+ type: inferCheckType(task.verifyCommand),
120
+ command: task.verifyCommand,
121
+ evidence_type_expected: 'stdout',
122
+ acceptance_ref: uncovered[0] ?? null,
123
+ description: `Verify command for task ${task.id}`,
124
+ });
125
+ }
126
+
127
+ return {
128
+ checks,
129
+ compiled_at: new Date().toISOString(),
130
+ spec_hash: hashObject(spec),
131
+ plan_hash: hashObject(plan),
132
+ };
133
+ }
134
+
135
+ export async function runCheck(
136
+ check: AcceptanceCheck,
137
+ cwd: string,
138
+ timeoutMs = 60_000
139
+ ): Promise<CheckResult> {
140
+ if (!check.command) {
141
+ return {
142
+ check_id: check.id,
143
+ acceptance_ref: check.acceptance_ref,
144
+ status: 'skip',
145
+ stdout: '',
146
+ stderr: '',
147
+ exit_code: null,
148
+ duration_ms: 0,
149
+ error: null,
150
+ };
151
+ }
152
+
153
+ const start = Date.now();
154
+ try {
155
+ // Split command into program + args (simple split; no shell expansion)
156
+ const parts = check.command.split(/\s+/);
157
+ const prog = parts[0];
158
+ const args = parts.slice(1);
159
+
160
+ const result = spawnSync(prog, args, {
161
+ cwd,
162
+ encoding: 'utf8',
163
+ timeout: timeoutMs,
164
+ maxBuffer: 2 * 1024 * 1024,
165
+ });
166
+
167
+ const duration_ms = Date.now() - start;
168
+ const status: VerificationStatus = result.status === 0 ? 'pass' : 'fail';
169
+
170
+ return {
171
+ check_id: check.id,
172
+ acceptance_ref: check.acceptance_ref,
173
+ status,
174
+ stdout: result.stdout ?? '',
175
+ stderr: result.stderr ?? '',
176
+ exit_code: result.status ?? null,
177
+ duration_ms,
178
+ error: result.error ? String(result.error) : null,
179
+ };
180
+ } catch (err) {
181
+ return {
182
+ check_id: check.id,
183
+ acceptance_ref: check.acceptance_ref,
184
+ status: 'error',
185
+ stdout: '',
186
+ stderr: '',
187
+ exit_code: null,
188
+ duration_ms: Date.now() - start,
189
+ error: String(err),
190
+ };
191
+ }
192
+ }
193
+
194
+ export async function runSuite(
195
+ suite: AcceptanceCheckSuite,
196
+ cwd: string,
197
+ timeoutMs = 60_000
198
+ ): Promise<CheckResult[]> {
199
+ const results: CheckResult[] = [];
200
+ for (const check of suite.checks) {
201
+ results.push(await runCheck(check, cwd, timeoutMs));
202
+ }
203
+ return results;
204
+ }
205
+
206
+ export function summarizeSuite(results: CheckResult[]): {
207
+ total: number;
208
+ pass: number;
209
+ fail: number;
210
+ skip: number;
211
+ error: number;
212
+ allPassed: boolean;
213
+ } {
214
+ const counts = { total: results.length, pass: 0, fail: 0, skip: 0, error: 0 };
215
+ for (const r of results) counts[r.status]++;
216
+ return { ...counts, allPassed: counts.fail === 0 && counts.error === 0 };
217
+ }
218
+
219
+ function hashObject(obj: unknown): string {
220
+ return crypto
221
+ .createHash('sha256')
222
+ .update(JSON.stringify(obj))
223
+ .digest('hex')
224
+ .slice(0, 12);
225
+ }
@@ -0,0 +1,5 @@
1
+ export * from './workspace-manager';
2
+ export { InplaceWorkspaceManager } from './strategies/inplace';
3
+ export { GitWorktreeManager } from './strategies/git-worktree';
4
+ export { EphemeralContainerManager } from './strategies/ephemeral-container';
5
+ export type { ContainerOptions } from './strategies/ephemeral-container';
@@ -0,0 +1,121 @@
1
+ import { execFileSync, spawnSync } from 'child_process';
2
+ import crypto from 'crypto';
3
+ import type { WorkspaceManager, WorkspaceRequest } from '../workspace-manager';
4
+ import type { WorkspaceLease, SnapshotRef } from '../../models/workspace';
5
+ import { GitWorktreeManager } from './git-worktree';
6
+
7
+ export interface ContainerOptions {
8
+ image: string;
9
+ mountPath: string;
10
+ extraEnv?: Record<string, string>;
11
+ /** Gracefully fall back to git_worktree if Docker is unavailable */
12
+ fallback?: boolean;
13
+ }
14
+
15
+ function isDockerAvailable(): boolean {
16
+ const result = spawnSync('docker', ['version', '--format', '{{.Server.Version}}'], {
17
+ encoding: 'utf8',
18
+ timeout: 5000,
19
+ });
20
+ return result.status === 0;
21
+ }
22
+
23
+ export class EphemeralContainerManager implements WorkspaceManager {
24
+ private readonly fallbackManager: GitWorktreeManager;
25
+ private containerIds = new Map<string, string>();
26
+ private useFallback = false;
27
+
28
+ constructor(
29
+ private readonly projectRoot: string,
30
+ private readonly opts: ContainerOptions = { image: 'node:20-alpine', mountPath: '/workspace', fallback: true }
31
+ ) {
32
+ this.fallbackManager = new GitWorktreeManager(projectRoot);
33
+ if (!isDockerAvailable()) {
34
+ if (opts.fallback !== false) {
35
+ this.useFallback = true;
36
+ } else {
37
+ throw new Error('Docker is not available and fallback is disabled');
38
+ }
39
+ }
40
+ }
41
+
42
+ get usingFallback(): boolean { return this.useFallback; }
43
+
44
+ async allocate(req: WorkspaceRequest): Promise<WorkspaceLease> {
45
+ if (this.useFallback) return this.fallbackManager.allocate(req);
46
+
47
+ const wsId = `ws-container-${req.work_item_id}-a${req.attempt_number}`;
48
+ const envArgs = Object.entries(this.opts.extraEnv ?? {}).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
49
+
50
+ const result = spawnSync('docker', [
51
+ 'run', '-d',
52
+ '-v', `${this.projectRoot}:${this.opts.mountPath}`,
53
+ '-w', this.opts.mountPath,
54
+ ...envArgs,
55
+ this.opts.image,
56
+ 'sleep', '3600',
57
+ ], { encoding: 'utf8' });
58
+
59
+ if (result.status !== 0) {
60
+ if (this.opts.fallback !== false) {
61
+ this.useFallback = true;
62
+ return this.fallbackManager.allocate(req);
63
+ }
64
+ throw new Error(`docker run failed: ${result.stderr}`);
65
+ }
66
+
67
+ const containerId = result.stdout.trim().slice(0, 12);
68
+ this.containerIds.set(wsId, containerId);
69
+
70
+ return {
71
+ workspace_id: wsId,
72
+ strategy: 'ephemeral_container',
73
+ branch: null,
74
+ base_commit: null,
75
+ root_path: `docker:${containerId}:${this.opts.mountPath}`,
76
+ ttl_minutes: 60,
77
+ };
78
+ }
79
+
80
+ async snapshot(id: string): Promise<SnapshotRef> {
81
+ if (this.useFallback) return this.fallbackManager.snapshot(id);
82
+ const containerId = this.containerIds.get(id);
83
+ if (!containerId) throw new Error(`Container for workspace ${id} not found`);
84
+
85
+ const tag = `oxe-snap-${crypto.randomBytes(4).toString('hex')}`;
86
+ execFileSync('docker', ['commit', containerId, tag]);
87
+
88
+ return {
89
+ snapshot_id: tag,
90
+ workspace_id: id,
91
+ commit: tag,
92
+ created_at: new Date().toISOString(),
93
+ };
94
+ }
95
+
96
+ async reset(id: string, snapRef: SnapshotRef): Promise<void> {
97
+ if (this.useFallback) return this.fallbackManager.reset(id, snapRef);
98
+ const containerId = this.containerIds.get(id);
99
+ if (!containerId) return;
100
+ // Stop current container and start from snapshot
101
+ spawnSync('docker', ['stop', containerId]);
102
+ spawnSync('docker', ['rm', containerId]);
103
+ const result = spawnSync('docker', [
104
+ 'run', '-d',
105
+ '-v', `${this.projectRoot}:${this.opts.mountPath}`,
106
+ snapRef.commit,
107
+ 'sleep', '3600',
108
+ ], { encoding: 'utf8' });
109
+ const newId = result.stdout.trim().slice(0, 12);
110
+ this.containerIds.set(id, newId);
111
+ }
112
+
113
+ async dispose(id: string): Promise<void> {
114
+ if (this.useFallback) return this.fallbackManager.dispose(id);
115
+ const containerId = this.containerIds.get(id);
116
+ if (!containerId) return;
117
+ spawnSync('docker', ['stop', containerId], { encoding: 'utf8' });
118
+ spawnSync('docker', ['rm', containerId], { encoding: 'utf8' });
119
+ this.containerIds.delete(id);
120
+ }
121
+ }