agentxchain 2.1.1 → 2.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/README.md +41 -9
- package/bin/agentxchain.js +89 -0
- package/package.json +7 -2
- package/scripts/publish-from-tag.sh +14 -9
- package/scripts/release-postflight.sh +42 -2
- package/src/commands/intake-approve.js +44 -0
- package/src/commands/intake-plan.js +62 -0
- package/src/commands/intake-record.js +86 -0
- package/src/commands/intake-resolve.js +45 -0
- package/src/commands/intake-scan.js +87 -0
- package/src/commands/intake-start.js +53 -0
- package/src/commands/intake-status.js +113 -0
- package/src/commands/intake-triage.js +54 -0
- package/src/commands/verify.js +8 -3
- package/src/lib/adapters/api-proxy-adapter.js +125 -27
- package/src/lib/intake.js +924 -0
- package/src/lib/normalized-config.js +10 -0
- package/src/lib/protocol-conformance.js +28 -4
- package/src/lib/reference-conformance-adapter.js +141 -0
- package/src/lib/repo-observer.js +1 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
+
import { safeWriteJson } from './safe-write.js';
|
|
5
|
+
import { VALID_GOVERNED_TEMPLATE_IDS, loadGovernedTemplate } from './governed-templates.js';
|
|
6
|
+
import {
|
|
7
|
+
initializeGovernedRun,
|
|
8
|
+
assignGovernedTurn,
|
|
9
|
+
getActiveTurns,
|
|
10
|
+
getActiveTurnCount,
|
|
11
|
+
STATE_PATH,
|
|
12
|
+
} from './governed-state.js';
|
|
13
|
+
import { loadProjectContext, loadProjectState } from './config.js';
|
|
14
|
+
import { writeDispatchBundle } from './dispatch-bundle.js';
|
|
15
|
+
import { finalizeDispatchManifest } from './dispatch-manifest.js';
|
|
16
|
+
import { getDispatchTurnDir } from './turn-paths.js';
|
|
17
|
+
|
|
18
|
+
const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule'];
|
|
19
|
+
const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
|
|
20
|
+
const EVENT_ID_RE = /^evt_\d+_[0-9a-f]{4}$/;
|
|
21
|
+
const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
|
|
22
|
+
|
|
23
|
+
// V3-S1 through S5 states
|
|
24
|
+
const S1_STATES = new Set(['detected', 'triaged', 'approved', 'planned', 'executing', 'blocked', 'completed', 'failed', 'suppressed', 'rejected']);
|
|
25
|
+
const TERMINAL_STATES = new Set(['suppressed', 'rejected', 'completed', 'failed']);
|
|
26
|
+
|
|
27
|
+
const VALID_TRANSITIONS = {
|
|
28
|
+
detected: ['triaged', 'suppressed'],
|
|
29
|
+
triaged: ['approved', 'rejected'],
|
|
30
|
+
approved: ['planned'],
|
|
31
|
+
planned: ['executing'],
|
|
32
|
+
executing: ['blocked', 'completed', 'failed'],
|
|
33
|
+
blocked: ['approved'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function generateId(prefix) {
|
|
41
|
+
const ts = Date.now();
|
|
42
|
+
const rand = randomBytes(2).toString('hex');
|
|
43
|
+
return `${prefix}_${ts}_${rand}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function computeDedupKey(source, signal) {
|
|
47
|
+
const sorted = JSON.stringify(signal, Object.keys(signal).sort());
|
|
48
|
+
const hash = createHash('sha256').update(sorted).digest('hex').slice(0, 16);
|
|
49
|
+
return `${source}:${hash}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function nowISO() {
|
|
53
|
+
return new Date().toISOString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function intakeDirs(root) {
|
|
57
|
+
const base = join(root, '.agentxchain', 'intake');
|
|
58
|
+
return {
|
|
59
|
+
base,
|
|
60
|
+
events: join(base, 'events'),
|
|
61
|
+
intents: join(base, 'intents'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ensureIntakeDirs(root) {
|
|
66
|
+
const dirs = intakeDirs(root);
|
|
67
|
+
mkdirSync(dirs.events, { recursive: true });
|
|
68
|
+
mkdirSync(dirs.intents, { recursive: true });
|
|
69
|
+
return dirs;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readJsonDir(dirPath) {
|
|
73
|
+
if (!existsSync(dirPath)) return [];
|
|
74
|
+
return readdirSync(dirPath)
|
|
75
|
+
.filter(f => f.endsWith('.json') && !f.startsWith('.tmp-'))
|
|
76
|
+
.map(f => {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(readFileSync(join(dirPath, f), 'utf8'));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Validation
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export function validateEventPayload(payload) {
|
|
91
|
+
const errors = [];
|
|
92
|
+
|
|
93
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
94
|
+
return { valid: false, errors: ['payload must be a JSON object'] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!VALID_SOURCES.includes(payload.source)) {
|
|
98
|
+
errors.push(`source must be one of: ${VALID_SOURCES.join(', ')}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!payload.signal || typeof payload.signal !== 'object' || Array.isArray(payload.signal) || Object.keys(payload.signal).length === 0) {
|
|
102
|
+
errors.push('signal must be a non-empty object');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!Array.isArray(payload.evidence) || payload.evidence.length === 0) {
|
|
106
|
+
errors.push('evidence must be a non-empty array');
|
|
107
|
+
} else {
|
|
108
|
+
for (const e of payload.evidence) {
|
|
109
|
+
if (!e || typeof e !== 'object') {
|
|
110
|
+
errors.push('each evidence entry must be an object');
|
|
111
|
+
} else {
|
|
112
|
+
if (!['url', 'file', 'text'].includes(e.type)) {
|
|
113
|
+
errors.push(`evidence type must be one of: url, file, text (got "${e.type}")`);
|
|
114
|
+
}
|
|
115
|
+
if (typeof e.value !== 'string' || !e.value.trim()) {
|
|
116
|
+
errors.push('evidence value must be a non-empty string');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { valid: errors.length === 0, errors };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function validateTriageFields(fields) {
|
|
126
|
+
const errors = [];
|
|
127
|
+
|
|
128
|
+
if (!VALID_PRIORITIES.includes(fields.priority)) {
|
|
129
|
+
errors.push(`priority must be one of: ${VALID_PRIORITIES.join(', ')}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!VALID_GOVERNED_TEMPLATE_IDS.includes(fields.template)) {
|
|
133
|
+
errors.push(`template must be one of: ${VALID_GOVERNED_TEMPLATE_IDS.join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof fields.charter !== 'string' || !fields.charter.trim()) {
|
|
137
|
+
errors.push('charter must be a non-empty string');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!Array.isArray(fields.acceptance_contract) || fields.acceptance_contract.length === 0) {
|
|
141
|
+
errors.push('acceptance_contract must be a non-empty array');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { valid: errors.length === 0, errors };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Record
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
export function recordEvent(root, payload) {
|
|
152
|
+
const validation = validateEventPayload(payload);
|
|
153
|
+
if (!validation.valid) {
|
|
154
|
+
return { ok: false, error: validation.errors.join('; '), exitCode: 1 };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const dirs = ensureIntakeDirs(root);
|
|
158
|
+
const dedupKey = computeDedupKey(payload.source, payload.signal);
|
|
159
|
+
|
|
160
|
+
// Check for duplicate
|
|
161
|
+
const existingEvents = readJsonDir(dirs.events);
|
|
162
|
+
const dup = existingEvents.find(e => e.dedup_key === dedupKey);
|
|
163
|
+
if (dup) {
|
|
164
|
+
const existingIntents = readJsonDir(dirs.intents);
|
|
165
|
+
const linkedIntent = existingIntents.find(i => i.event_id === dup.event_id);
|
|
166
|
+
return { ok: true, event: dup, intent: linkedIntent || null, deduplicated: true, exitCode: 0 };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const now = nowISO();
|
|
170
|
+
const eventId = generateId('evt');
|
|
171
|
+
const event = {
|
|
172
|
+
schema_version: '1.0',
|
|
173
|
+
event_id: eventId,
|
|
174
|
+
source: payload.source,
|
|
175
|
+
category: payload.category || `${payload.source}_signal`,
|
|
176
|
+
created_at: now,
|
|
177
|
+
repo: payload.repo || null,
|
|
178
|
+
ref: payload.ref || null,
|
|
179
|
+
signal: payload.signal,
|
|
180
|
+
evidence: payload.evidence,
|
|
181
|
+
dedup_key: dedupKey,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
safeWriteJson(join(dirs.events, `${eventId}.json`), event);
|
|
185
|
+
|
|
186
|
+
// Create detected intent
|
|
187
|
+
const intentId = generateId('intent');
|
|
188
|
+
const intent = {
|
|
189
|
+
schema_version: '1.0',
|
|
190
|
+
intent_id: intentId,
|
|
191
|
+
event_id: eventId,
|
|
192
|
+
status: 'detected',
|
|
193
|
+
priority: null,
|
|
194
|
+
template: null,
|
|
195
|
+
charter: null,
|
|
196
|
+
acceptance_contract: [],
|
|
197
|
+
requires_human_start: true,
|
|
198
|
+
target_run: null,
|
|
199
|
+
created_at: now,
|
|
200
|
+
updated_at: now,
|
|
201
|
+
history: [
|
|
202
|
+
{ from: null, to: 'detected', at: now, reason: 'event ingested' },
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
safeWriteJson(join(dirs.intents, `${intentId}.json`), intent);
|
|
207
|
+
|
|
208
|
+
return { ok: true, event, intent, deduplicated: false, exitCode: 0 };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Triage
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
export function triageIntent(root, intentId, fields) {
|
|
216
|
+
const dirs = intakeDirs(root);
|
|
217
|
+
const intentPath = join(dirs.intents, `${intentId}.json`);
|
|
218
|
+
|
|
219
|
+
if (!existsSync(intentPath)) {
|
|
220
|
+
return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
224
|
+
|
|
225
|
+
// Suppress path
|
|
226
|
+
if (fields.suppress) {
|
|
227
|
+
if (intent.status !== 'detected') {
|
|
228
|
+
return { ok: false, error: `cannot suppress from status "${intent.status}" (must be detected)`, exitCode: 1 };
|
|
229
|
+
}
|
|
230
|
+
if (!fields.reason) {
|
|
231
|
+
return { ok: false, error: 'suppress requires --reason', exitCode: 1 };
|
|
232
|
+
}
|
|
233
|
+
const now = nowISO();
|
|
234
|
+
intent.status = 'suppressed';
|
|
235
|
+
intent.updated_at = now;
|
|
236
|
+
intent.history.push({ from: 'detected', to: 'suppressed', at: now, reason: fields.reason });
|
|
237
|
+
safeWriteJson(intentPath, intent);
|
|
238
|
+
return { ok: true, intent, exitCode: 0 };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Reject path
|
|
242
|
+
if (fields.reject) {
|
|
243
|
+
if (intent.status !== 'triaged') {
|
|
244
|
+
return { ok: false, error: `cannot reject from status "${intent.status}" (must be triaged)`, exitCode: 1 };
|
|
245
|
+
}
|
|
246
|
+
if (!fields.reason) {
|
|
247
|
+
return { ok: false, error: 'reject requires --reason', exitCode: 1 };
|
|
248
|
+
}
|
|
249
|
+
const now = nowISO();
|
|
250
|
+
intent.status = 'rejected';
|
|
251
|
+
intent.updated_at = now;
|
|
252
|
+
intent.history.push({ from: 'triaged', to: 'rejected', at: now, reason: fields.reason });
|
|
253
|
+
safeWriteJson(intentPath, intent);
|
|
254
|
+
return { ok: true, intent, exitCode: 0 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Triage path: detected -> triaged
|
|
258
|
+
if (intent.status !== 'detected') {
|
|
259
|
+
return { ok: false, error: `cannot triage from status "${intent.status}" (must be detected)`, exitCode: 1 };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const validation = validateTriageFields(fields);
|
|
263
|
+
if (!validation.valid) {
|
|
264
|
+
return { ok: false, error: validation.errors.join('; '), exitCode: 1 };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const now = nowISO();
|
|
268
|
+
intent.status = 'triaged';
|
|
269
|
+
intent.priority = fields.priority;
|
|
270
|
+
intent.template = fields.template;
|
|
271
|
+
intent.charter = fields.charter;
|
|
272
|
+
intent.acceptance_contract = fields.acceptance_contract;
|
|
273
|
+
intent.updated_at = now;
|
|
274
|
+
intent.history.push({ from: 'detected', to: 'triaged', at: now, reason: 'triage completed' });
|
|
275
|
+
|
|
276
|
+
safeWriteJson(intentPath, intent);
|
|
277
|
+
return { ok: true, intent, exitCode: 0 };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Status
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
export function intakeStatus(root, intentId) {
|
|
285
|
+
const dirs = intakeDirs(root);
|
|
286
|
+
|
|
287
|
+
if (intentId) {
|
|
288
|
+
const intentPath = join(dirs.intents, `${intentId}.json`);
|
|
289
|
+
if (!existsSync(intentPath)) {
|
|
290
|
+
return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
|
|
291
|
+
}
|
|
292
|
+
const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
293
|
+
const eventPath = join(dirs.events, `${intent.event_id}.json`);
|
|
294
|
+
const event = existsSync(eventPath) ? JSON.parse(readFileSync(eventPath, 'utf8')) : null;
|
|
295
|
+
return { ok: true, intent, event, exitCode: 0 };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const events = readJsonDir(dirs.events);
|
|
299
|
+
const intents = readJsonDir(dirs.intents);
|
|
300
|
+
|
|
301
|
+
const counts = {};
|
|
302
|
+
for (const intent of intents) {
|
|
303
|
+
counts[intent.status] = (counts[intent.status] || 0) + 1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const summary = {
|
|
307
|
+
schema_version: '1.0',
|
|
308
|
+
last_updated_at: nowISO(),
|
|
309
|
+
total_events: events.length,
|
|
310
|
+
total_intents: intents.length,
|
|
311
|
+
by_status: counts,
|
|
312
|
+
intents: intents
|
|
313
|
+
.sort((a, b) => (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at))
|
|
314
|
+
.map(i => ({
|
|
315
|
+
intent_id: i.intent_id,
|
|
316
|
+
priority: i.priority,
|
|
317
|
+
template: i.template,
|
|
318
|
+
status: i.status,
|
|
319
|
+
updated_at: i.updated_at,
|
|
320
|
+
})),
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Write loop-state cache
|
|
324
|
+
const loopState = {
|
|
325
|
+
schema_version: '1.0',
|
|
326
|
+
last_updated_at: summary.last_updated_at,
|
|
327
|
+
pending_events: events.filter(e => {
|
|
328
|
+
const linked = intents.find(i => i.event_id === e.event_id);
|
|
329
|
+
return !linked || linked.status === 'detected';
|
|
330
|
+
}).length,
|
|
331
|
+
pending_intents: intents.filter(i => i.status === 'detected' || i.status === 'triaged').length,
|
|
332
|
+
active_intents: intents.filter(i => !TERMINAL_STATES.has(i.status) && i.status !== 'detected').length,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
ensureIntakeDirs(root);
|
|
337
|
+
safeWriteJson(join(dirs.base, 'loop-state.json'), loopState);
|
|
338
|
+
} catch {
|
|
339
|
+
// non-fatal — loop-state is a cache
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { ok: true, summary, exitCode: 0 };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Approve
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
export function approveIntent(root, intentId, options = {}) {
|
|
350
|
+
const dirs = intakeDirs(root);
|
|
351
|
+
const intentPath = join(dirs.intents, `${intentId}.json`);
|
|
352
|
+
|
|
353
|
+
if (!existsSync(intentPath)) {
|
|
354
|
+
return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
358
|
+
|
|
359
|
+
if (intent.status !== 'triaged' && intent.status !== 'blocked') {
|
|
360
|
+
return { ok: false, error: `cannot approve from status "${intent.status}" (must be triaged or blocked)`, exitCode: 1 };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const approver = options.approver || 'operator';
|
|
364
|
+
const previousStatus = intent.status;
|
|
365
|
+
const reason = options.reason || (previousStatus === 'blocked' ? 're-approved after block resolution' : 'approved for planning');
|
|
366
|
+
const now = nowISO();
|
|
367
|
+
|
|
368
|
+
intent.status = 'approved';
|
|
369
|
+
intent.approved_by = approver;
|
|
370
|
+
intent.updated_at = now;
|
|
371
|
+
intent.history.push({ from: previousStatus, to: 'approved', at: now, reason, approver });
|
|
372
|
+
|
|
373
|
+
safeWriteJson(intentPath, intent);
|
|
374
|
+
return { ok: true, intent, exitCode: 0 };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Plan
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
export function planIntent(root, intentId, options = {}) {
|
|
382
|
+
const dirs = intakeDirs(root);
|
|
383
|
+
const intentPath = join(dirs.intents, `${intentId}.json`);
|
|
384
|
+
|
|
385
|
+
if (!existsSync(intentPath)) {
|
|
386
|
+
return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
390
|
+
|
|
391
|
+
if (intent.status !== 'approved') {
|
|
392
|
+
return { ok: false, error: `cannot plan from status "${intent.status}" (must be approved)`, exitCode: 1 };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Load the governed template
|
|
396
|
+
let manifest;
|
|
397
|
+
try {
|
|
398
|
+
manifest = loadGovernedTemplate(intent.template);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
return { ok: false, error: err.message, exitCode: 2 };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const planningDir = join(root, '.planning');
|
|
404
|
+
const projectName = options.projectName || basename(root);
|
|
405
|
+
const artifacts = manifest.planning_artifacts || [];
|
|
406
|
+
|
|
407
|
+
// Check for conflicts
|
|
408
|
+
if (!options.force) {
|
|
409
|
+
const conflicts = [];
|
|
410
|
+
for (const artifact of artifacts) {
|
|
411
|
+
const targetPath = join(planningDir, artifact.filename);
|
|
412
|
+
if (existsSync(targetPath)) {
|
|
413
|
+
conflicts.push(`.planning/${artifact.filename}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (conflicts.length > 0) {
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
error: 'existing planning artifacts would be overwritten',
|
|
420
|
+
conflicts,
|
|
421
|
+
exitCode: 1,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Generate artifacts
|
|
427
|
+
mkdirSync(planningDir, { recursive: true });
|
|
428
|
+
const generated = [];
|
|
429
|
+
for (const artifact of artifacts) {
|
|
430
|
+
const targetPath = join(planningDir, artifact.filename);
|
|
431
|
+
const content = artifact.content_template.replace(/\{\{project_name\}\}/g, projectName);
|
|
432
|
+
writeFileSync(targetPath, content + '\n');
|
|
433
|
+
generated.push(`.planning/${artifact.filename}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const now = nowISO();
|
|
437
|
+
intent.status = 'planned';
|
|
438
|
+
intent.planning_artifacts = generated;
|
|
439
|
+
intent.updated_at = now;
|
|
440
|
+
intent.history.push({
|
|
441
|
+
from: 'approved',
|
|
442
|
+
to: 'planned',
|
|
443
|
+
at: now,
|
|
444
|
+
reason: `generated ${generated.length} planning artifact(s) from template "${intent.template}"`,
|
|
445
|
+
artifacts: generated,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
safeWriteJson(intentPath, intent);
|
|
449
|
+
return { ok: true, intent, artifacts_generated: generated, artifacts_skipped: [], exitCode: 0 };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// Start — planned → executing bridge (V3-S3)
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
export function startIntent(root, intentId, options = {}) {
|
|
457
|
+
const dirs = intakeDirs(root);
|
|
458
|
+
const intentPath = join(dirs.intents, `${intentId}.json`);
|
|
459
|
+
|
|
460
|
+
if (!existsSync(intentPath)) {
|
|
461
|
+
return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
465
|
+
|
|
466
|
+
if (intent.status !== 'planned') {
|
|
467
|
+
return { ok: false, error: `cannot start from status "${intent.status}" (must be planned)`, exitCode: 1 };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Verify planning artifacts still exist on disk
|
|
471
|
+
const planningArtifacts = intent.planning_artifacts || [];
|
|
472
|
+
const missingArtifacts = [];
|
|
473
|
+
for (const relPath of planningArtifacts) {
|
|
474
|
+
if (!existsSync(join(root, relPath))) {
|
|
475
|
+
missingArtifacts.push(relPath);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (missingArtifacts.length > 0) {
|
|
479
|
+
return {
|
|
480
|
+
ok: false,
|
|
481
|
+
error: 'recorded planning artifacts are missing on disk',
|
|
482
|
+
missing: missingArtifacts,
|
|
483
|
+
exitCode: 1,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Load governed project context
|
|
488
|
+
const context = loadProjectContext(root);
|
|
489
|
+
if (!context) {
|
|
490
|
+
return { ok: false, error: 'agentxchain.json not found or invalid', exitCode: 2 };
|
|
491
|
+
}
|
|
492
|
+
const { config } = context;
|
|
493
|
+
|
|
494
|
+
if (config.protocol_mode !== 'governed') {
|
|
495
|
+
return { ok: false, error: 'intake start requires a governed project', exitCode: 2 };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Load governed state
|
|
499
|
+
const statePath = join(root, STATE_PATH);
|
|
500
|
+
if (!existsSync(statePath)) {
|
|
501
|
+
return { ok: false, error: 'No governed state.json found', exitCode: 2 };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let state = loadProjectState(root, config);
|
|
505
|
+
if (!state) {
|
|
506
|
+
return { ok: false, error: 'Failed to parse governed state.json', exitCode: 2 };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Check busy-run conditions
|
|
510
|
+
const activeTurns = getActiveTurns(state);
|
|
511
|
+
const activeCount = getActiveTurnCount(state);
|
|
512
|
+
|
|
513
|
+
if (activeCount > 0) {
|
|
514
|
+
const turnIds = Object.keys(activeTurns);
|
|
515
|
+
return {
|
|
516
|
+
ok: false,
|
|
517
|
+
error: `cannot start: active turn(s) already exist: ${turnIds.join(', ')}`,
|
|
518
|
+
exitCode: 1,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (state.status === 'blocked') {
|
|
523
|
+
const reason = state.blocked_reason?.recovery?.detail || state.blocked_on || 'unknown';
|
|
524
|
+
return { ok: false, error: `cannot start: run is blocked (${reason})`, exitCode: 1 };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (state.status === 'completed') {
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
error: 'cannot start: governed run is already completed. S3 does not reopen completed runs.',
|
|
531
|
+
exitCode: 1,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (state.pending_phase_transition) {
|
|
536
|
+
return { ok: false, error: `cannot start: pending phase transition to "${state.pending_phase_transition}"`, exitCode: 1 };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (state.pending_run_completion) {
|
|
540
|
+
return { ok: false, error: 'cannot start: pending run completion approval', exitCode: 1 };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Bootstrap: idle with no run → initialize
|
|
544
|
+
if (state.status === 'idle' && !state.run_id) {
|
|
545
|
+
const initResult = initializeGovernedRun(root, config);
|
|
546
|
+
if (!initResult.ok) {
|
|
547
|
+
return { ok: false, error: `run initialization failed: ${initResult.error}`, exitCode: 1 };
|
|
548
|
+
}
|
|
549
|
+
state = initResult.state;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Resume: paused with no active turns → reactivate
|
|
553
|
+
if (state.status === 'paused' && state.run_id) {
|
|
554
|
+
state.status = 'active';
|
|
555
|
+
state.blocked_on = null;
|
|
556
|
+
state.escalation = null;
|
|
557
|
+
safeWriteJson(statePath, state);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Resolve role
|
|
561
|
+
const roleId = resolveIntakeRole(options.role, state, config);
|
|
562
|
+
if (!roleId.ok) {
|
|
563
|
+
return { ok: false, error: roleId.error, exitCode: 1 };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Assign governed turn
|
|
567
|
+
const assignResult = assignGovernedTurn(root, config, roleId.role);
|
|
568
|
+
if (!assignResult.ok) {
|
|
569
|
+
return { ok: false, error: `turn assignment failed: ${assignResult.error}`, exitCode: 1 };
|
|
570
|
+
}
|
|
571
|
+
state = assignResult.state;
|
|
572
|
+
|
|
573
|
+
// Find the newly assigned turn
|
|
574
|
+
const newActiveTurns = getActiveTurns(state);
|
|
575
|
+
const assignedTurn = Object.values(newActiveTurns).find(t => t.assigned_role === roleId.role);
|
|
576
|
+
if (!assignedTurn) {
|
|
577
|
+
return { ok: false, error: 'turn assignment succeeded but turn not found in state', exitCode: 1 };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Write dispatch bundle
|
|
581
|
+
const bundleResult = writeDispatchBundle(root, state, config);
|
|
582
|
+
if (!bundleResult.ok) {
|
|
583
|
+
return { ok: false, error: `dispatch bundle failed: ${bundleResult.error}`, exitCode: 1 };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Finalize dispatch manifest
|
|
587
|
+
finalizeDispatchManifest(root, assignedTurn.turn_id, {
|
|
588
|
+
run_id: state.run_id,
|
|
589
|
+
role: assignedTurn.assigned_role,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// Update intent: planned → executing
|
|
593
|
+
const now = nowISO();
|
|
594
|
+
intent.status = 'executing';
|
|
595
|
+
intent.target_run = state.run_id;
|
|
596
|
+
intent.target_turn = assignedTurn.turn_id;
|
|
597
|
+
intent.started_at = now;
|
|
598
|
+
intent.updated_at = now;
|
|
599
|
+
intent.history.push({
|
|
600
|
+
from: 'planned',
|
|
601
|
+
to: 'executing',
|
|
602
|
+
at: now,
|
|
603
|
+
run_id: state.run_id,
|
|
604
|
+
turn_id: assignedTurn.turn_id,
|
|
605
|
+
role: roleId.role,
|
|
606
|
+
reason: 'governed execution started',
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
safeWriteJson(intentPath, intent);
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
ok: true,
|
|
613
|
+
intent,
|
|
614
|
+
run_id: state.run_id,
|
|
615
|
+
turn_id: assignedTurn.turn_id,
|
|
616
|
+
role: roleId.role,
|
|
617
|
+
dispatch_dir: getDispatchTurnDir(assignedTurn.turn_id),
|
|
618
|
+
exitCode: 0,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function resolveIntakeRole(roleOverride, state, config) {
|
|
623
|
+
const phase = state.phase;
|
|
624
|
+
const routing = config.routing?.[phase];
|
|
625
|
+
|
|
626
|
+
if (roleOverride) {
|
|
627
|
+
if (!config.roles?.[roleOverride]) {
|
|
628
|
+
const available = Object.keys(config.roles || {}).join(', ');
|
|
629
|
+
return { ok: false, error: `unknown role: "${roleOverride}". Available: ${available}` };
|
|
630
|
+
}
|
|
631
|
+
return { ok: true, role: roleOverride };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (routing?.entry_role) {
|
|
635
|
+
return { ok: true, role: routing.entry_role };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const roles = Object.keys(config.roles || {});
|
|
639
|
+
if (roles.length > 0) {
|
|
640
|
+
return { ok: true, role: roles[0] };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return { ok: false, error: 'no roles defined in project config' };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
// Resolve — execution exit and intent closure linkage (V3-S5)
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
export function resolveIntent(root, intentId) {
|
|
651
|
+
const dirs = intakeDirs(root);
|
|
652
|
+
const intentPath = join(dirs.intents, `${intentId}.json`);
|
|
653
|
+
|
|
654
|
+
if (!existsSync(intentPath)) {
|
|
655
|
+
return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
659
|
+
|
|
660
|
+
if (intent.status !== 'executing') {
|
|
661
|
+
return { ok: false, error: `cannot resolve from status "${intent.status}" (must be executing)`, exitCode: 1 };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (!intent.target_run) {
|
|
665
|
+
return { ok: false, error: `intent ${intentId} has no linked run (target_run is null)`, exitCode: 1 };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Load governed state
|
|
669
|
+
const statePath = join(root, STATE_PATH);
|
|
670
|
+
if (!existsSync(statePath)) {
|
|
671
|
+
return { ok: false, error: 'governed state not found at .agentxchain/state.json', exitCode: 1 };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let state;
|
|
675
|
+
try {
|
|
676
|
+
state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
677
|
+
} catch {
|
|
678
|
+
return { ok: false, error: 'failed to parse governed state.json', exitCode: 1 };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Validate run identity
|
|
682
|
+
if (state.run_id !== intent.target_run) {
|
|
683
|
+
return {
|
|
684
|
+
ok: false,
|
|
685
|
+
error: `run_id mismatch: intent targets ${intent.target_run} but governed state has ${state.run_id}`,
|
|
686
|
+
exitCode: 1,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (state.status === 'idle') {
|
|
691
|
+
return {
|
|
692
|
+
ok: false,
|
|
693
|
+
error: 'governed run is idle — state may have been reset after intent start',
|
|
694
|
+
exitCode: 1,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Map run outcome to intent transition
|
|
699
|
+
const now = nowISO();
|
|
700
|
+
const previousStatus = intent.status;
|
|
701
|
+
|
|
702
|
+
if (state.status === 'blocked' || state.status === 'failed') {
|
|
703
|
+
const newStatus = state.status === 'blocked' ? 'blocked' : 'failed';
|
|
704
|
+
intent.status = newStatus;
|
|
705
|
+
intent.run_blocked_on = state.blocked_on || null;
|
|
706
|
+
intent.run_blocked_reason = state.blocked_reason?.category || null;
|
|
707
|
+
intent.run_blocked_recovery = state.blocked_reason?.recovery?.recovery_action || null;
|
|
708
|
+
if (newStatus === 'failed') {
|
|
709
|
+
intent.run_failed_at = now;
|
|
710
|
+
}
|
|
711
|
+
intent.updated_at = now;
|
|
712
|
+
intent.history.push({
|
|
713
|
+
from: previousStatus,
|
|
714
|
+
to: newStatus,
|
|
715
|
+
at: now,
|
|
716
|
+
reason: `governed run ${intent.target_run} reached status ${state.status}`,
|
|
717
|
+
run_id: intent.target_run,
|
|
718
|
+
run_status: state.status,
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
safeWriteJson(intentPath, intent);
|
|
722
|
+
return {
|
|
723
|
+
ok: true,
|
|
724
|
+
intent,
|
|
725
|
+
previous_status: previousStatus,
|
|
726
|
+
new_status: newStatus,
|
|
727
|
+
run_outcome: state.status,
|
|
728
|
+
no_change: false,
|
|
729
|
+
exitCode: 0,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (state.status === 'completed') {
|
|
734
|
+
intent.status = 'completed';
|
|
735
|
+
intent.run_completed_at = state.completed_at || now;
|
|
736
|
+
intent.run_final_turn = state.last_completed_turn_id || null;
|
|
737
|
+
intent.updated_at = now;
|
|
738
|
+
intent.history.push({
|
|
739
|
+
from: previousStatus,
|
|
740
|
+
to: 'completed',
|
|
741
|
+
at: now,
|
|
742
|
+
reason: `governed run ${intent.target_run} reached status completed`,
|
|
743
|
+
run_id: intent.target_run,
|
|
744
|
+
run_status: 'completed',
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// Create observation directory scaffold
|
|
748
|
+
const obsDir = join(dirs.base, 'observations', intentId);
|
|
749
|
+
mkdirSync(obsDir, { recursive: true });
|
|
750
|
+
|
|
751
|
+
safeWriteJson(intentPath, intent);
|
|
752
|
+
return {
|
|
753
|
+
ok: true,
|
|
754
|
+
intent,
|
|
755
|
+
previous_status: previousStatus,
|
|
756
|
+
new_status: 'completed',
|
|
757
|
+
run_outcome: 'completed',
|
|
758
|
+
no_change: false,
|
|
759
|
+
exitCode: 0,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// active or paused — no transition yet
|
|
764
|
+
return {
|
|
765
|
+
ok: true,
|
|
766
|
+
intent,
|
|
767
|
+
previous_status: previousStatus,
|
|
768
|
+
new_status: previousStatus,
|
|
769
|
+
run_outcome: state.status,
|
|
770
|
+
no_change: true,
|
|
771
|
+
exitCode: 0,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
// Scan — deterministic source-snapshot ingestion (V3-S4)
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
const SCAN_SOURCES = ['ci_failure', 'git_ref_change', 'schedule'];
|
|
780
|
+
|
|
781
|
+
function validateSnapshotItem(item) {
|
|
782
|
+
const errors = [];
|
|
783
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
784
|
+
return { valid: false, errors: ['item must be a JSON object'] };
|
|
785
|
+
}
|
|
786
|
+
if (!item.signal || typeof item.signal !== 'object' || Array.isArray(item.signal) || Object.keys(item.signal).length === 0) {
|
|
787
|
+
errors.push('signal must be a non-empty object');
|
|
788
|
+
}
|
|
789
|
+
if (!Array.isArray(item.evidence) || item.evidence.length === 0) {
|
|
790
|
+
errors.push('evidence must be a non-empty array');
|
|
791
|
+
} else {
|
|
792
|
+
for (const e of item.evidence) {
|
|
793
|
+
if (!e || typeof e !== 'object') {
|
|
794
|
+
errors.push('each evidence entry must be an object');
|
|
795
|
+
} else {
|
|
796
|
+
if (!['url', 'file', 'text'].includes(e.type)) {
|
|
797
|
+
errors.push(`evidence type must be one of: url, file, text (got "${e.type}")`);
|
|
798
|
+
}
|
|
799
|
+
if (typeof e.value !== 'string' || !e.value.trim()) {
|
|
800
|
+
errors.push('evidence value must be a non-empty string');
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return { valid: errors.length === 0, errors };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export function scanSource(root, source, snapshot) {
|
|
809
|
+
// Validate source
|
|
810
|
+
if (!SCAN_SOURCES.includes(source)) {
|
|
811
|
+
return {
|
|
812
|
+
ok: false,
|
|
813
|
+
error: `unknown scan source: "${source}". Supported: ${SCAN_SOURCES.join(', ')}`,
|
|
814
|
+
exitCode: 1,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Validate snapshot structure
|
|
819
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
|
820
|
+
return { ok: false, error: 'snapshot must be a JSON object', exitCode: 1 };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (snapshot.source !== source) {
|
|
824
|
+
return {
|
|
825
|
+
ok: false,
|
|
826
|
+
error: `source mismatch: CLI flag "${source}" but snapshot declares "${snapshot.source}"`,
|
|
827
|
+
exitCode: 1,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (!Array.isArray(snapshot.items) || snapshot.items.length === 0) {
|
|
832
|
+
return { ok: false, error: 'snapshot must contain a non-empty items array', exitCode: 1 };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const results = [];
|
|
836
|
+
let created = 0;
|
|
837
|
+
let deduplicated = 0;
|
|
838
|
+
let rejected = 0;
|
|
839
|
+
|
|
840
|
+
for (let i = 0; i < snapshot.items.length; i++) {
|
|
841
|
+
const item = snapshot.items[i];
|
|
842
|
+
|
|
843
|
+
// Validate item structure
|
|
844
|
+
const validation = validateSnapshotItem(item);
|
|
845
|
+
if (!validation.valid) {
|
|
846
|
+
results.push({
|
|
847
|
+
status: 'rejected',
|
|
848
|
+
index: i,
|
|
849
|
+
error: validation.errors.join('; '),
|
|
850
|
+
});
|
|
851
|
+
rejected++;
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Build recordEvent payload from snapshot item
|
|
856
|
+
const payload = {
|
|
857
|
+
source,
|
|
858
|
+
signal: item.signal,
|
|
859
|
+
evidence: item.evidence,
|
|
860
|
+
category: item.category || undefined,
|
|
861
|
+
repo: item.repo || undefined,
|
|
862
|
+
ref: item.ref || undefined,
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
const recordResult = recordEvent(root, payload);
|
|
866
|
+
if (!recordResult.ok) {
|
|
867
|
+
results.push({
|
|
868
|
+
status: 'rejected',
|
|
869
|
+
index: i,
|
|
870
|
+
error: recordResult.error,
|
|
871
|
+
});
|
|
872
|
+
rejected++;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (recordResult.deduplicated) {
|
|
877
|
+
results.push({
|
|
878
|
+
status: 'deduplicated',
|
|
879
|
+
event_id: recordResult.event.event_id,
|
|
880
|
+
intent_id: recordResult.intent?.intent_id || null,
|
|
881
|
+
});
|
|
882
|
+
deduplicated++;
|
|
883
|
+
} else {
|
|
884
|
+
results.push({
|
|
885
|
+
status: 'created',
|
|
886
|
+
event_id: recordResult.event.event_id,
|
|
887
|
+
intent_id: recordResult.intent?.intent_id || null,
|
|
888
|
+
});
|
|
889
|
+
created++;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// If every item was rejected, fail
|
|
894
|
+
if (created === 0 && deduplicated === 0) {
|
|
895
|
+
return {
|
|
896
|
+
ok: false,
|
|
897
|
+
error: 'all scanned items were rejected',
|
|
898
|
+
source,
|
|
899
|
+
scanned: snapshot.items.length,
|
|
900
|
+
created,
|
|
901
|
+
deduplicated,
|
|
902
|
+
rejected,
|
|
903
|
+
results,
|
|
904
|
+
exitCode: 1,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return {
|
|
909
|
+
ok: true,
|
|
910
|
+
source,
|
|
911
|
+
scanned: snapshot.items.length,
|
|
912
|
+
created,
|
|
913
|
+
deduplicated,
|
|
914
|
+
rejected,
|
|
915
|
+
results,
|
|
916
|
+
exitCode: 0,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ---------------------------------------------------------------------------
|
|
921
|
+
// Exports for testing
|
|
922
|
+
// ---------------------------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
export { VALID_SOURCES, VALID_PRIORITIES, VALID_TRANSITIONS, S1_STATES, TERMINAL_STATES, SCAN_SOURCES };
|