@nyxa/nyx-agent 0.2.1 → 0.3.1

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.
@@ -1,11 +1,15 @@
1
1
  import path from "node:path";
2
+ import { checkbox, Separator } from "@inquirer/prompts";
2
3
  import pc from "picocolors";
3
4
  import { loadConfig } from "../config/loadConfig.js";
4
5
  import { ensureDir, writeJson } from "./files.js";
5
6
  import { getGitSnapshot } from "./git.js";
7
+ import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger } from "./ledger.js";
6
8
  import { getNyxDir, relativeToProject } from "./paths.js";
7
9
  import { runPhase } from "./runPhase.js";
8
10
  import { createRunId } from "./time.js";
11
+ import { validateWorkItemQueue } from "./validateWorkItem.js";
12
+ import { filterAvailableWorkItems, listWorkItemCandidates } from "./workItems.js";
9
13
  export async function runWorkflow(options) {
10
14
  const projectRoot = path.resolve(options.projectRoot);
11
15
  const nyxDir = getNyxDir(projectRoot);
@@ -14,13 +18,21 @@ export async function runWorkflow(options) {
14
18
  const runId = createRunId();
15
19
  const runDir = path.join(nyxDir, "runs", runId);
16
20
  await ensureDir(runDir);
21
+ let ledger = await readWorkItemLedger(nyxDir);
22
+ await writeWorkItemLedger(nyxDir, ledger);
17
23
  const git = await getGitSnapshot(projectRoot);
18
24
  const runState = {
19
25
  run_id: runId,
20
26
  status: "running",
21
27
  current_iteration: 0,
22
28
  completed_iterations: 0,
23
- seen_work_item_keys: []
29
+ available_work_items: [],
30
+ selected_work_item_queue: [],
31
+ skipped_work_item_keys: [],
32
+ selection_groups: [],
33
+ seen_work_item_keys: [],
34
+ completed_work_item_keys: [...ledger.completed_work_item_keys],
35
+ last_completed_work_item: ledger.last_completed_work_item
24
36
  };
25
37
  await writeJson(path.join(runDir, "run.json"), {
26
38
  run_id: runId,
@@ -40,7 +52,58 @@ export async function runWorkflow(options) {
40
52
  console.log(`Model: ${config.model.name}`);
41
53
  console.log("");
42
54
  const phasesById = new Map(config.phases.map((phase) => [phase.id, phase]));
43
- for (let iterationNumber = 1; iterationNumber <= config.workflow.max_iterations; iterationNumber += 1) {
55
+ const selection = await runSelectionPhase({
56
+ projectRoot,
57
+ runDir,
58
+ config,
59
+ phasesById,
60
+ runState,
61
+ ledger
62
+ });
63
+ if (selection.status === "stopped") {
64
+ return;
65
+ }
66
+ runState.available_work_items = selection.availableWorkItems;
67
+ runState.selection_groups = selection.selectionGroups;
68
+ await writeJson(path.join(runDir, "state.json"), runState);
69
+ let confirmedWorkItems;
70
+ try {
71
+ confirmedWorkItems = normalizeConfirmedWorkItems({
72
+ queue: selection.workItems,
73
+ confirmed: await (options.confirmWorkItems ?? confirmWorkItemsWithCheckbox)({
74
+ workItems: selection.workItems,
75
+ selectionGroups: selection.selectionGroups,
76
+ maxIterations: config.workflow.max_iterations
77
+ })
78
+ });
79
+ }
80
+ catch (error) {
81
+ runState.status = "failed";
82
+ await writeJson(path.join(runDir, "state.json"), runState);
83
+ throw error;
84
+ }
85
+ const selectedWorkItems = confirmedWorkItems.slice(0, config.workflow.max_iterations);
86
+ const selectedKeys = new Set(selectedWorkItems.map((item) => item.key));
87
+ runState.selected_work_item_queue = selectedWorkItems;
88
+ runState.skipped_work_item_keys = selection.workItems
89
+ .filter((item) => !selectedKeys.has(item.key))
90
+ .map((item) => item.key);
91
+ runState.seen_work_item_keys = selectedWorkItems.map((item) => item.key);
92
+ await writeJson(path.join(runDir, "state.json"), runState);
93
+ if (confirmedWorkItems.length > config.workflow.max_iterations) {
94
+ console.log(pc.yellow(`Only the first ${config.workflow.max_iterations} selected work items will run.`));
95
+ }
96
+ if (selectedWorkItems.length === 0) {
97
+ runState.status = "completed_no_selected_work";
98
+ await writeJson(path.join(runDir, "state.json"), runState);
99
+ console.log("");
100
+ console.log(`Done: ${runState.status}`);
101
+ console.log(`Run dir: ${relativeToProject(projectRoot, runDir)}`);
102
+ return;
103
+ }
104
+ for (let queueIndex = 0; queueIndex < selectedWorkItems.length; queueIndex += 1) {
105
+ const iterationNumber = queueIndex + 1;
106
+ const workItem = selectedWorkItems[queueIndex];
44
107
  runState.current_iteration = iterationNumber;
45
108
  await writeJson(path.join(runDir, "state.json"), runState);
46
109
  const iterationDir = path.join(runDir, "iterations", String(iterationNumber).padStart(3, "0"));
@@ -48,68 +111,42 @@ export async function runWorkflow(options) {
48
111
  const iterationState = {
49
112
  iteration: iterationNumber,
50
113
  status: "running",
114
+ work_item: workItem,
115
+ available_work_items: selectedWorkItems,
116
+ selected_work_item_queue: selectedWorkItems,
51
117
  seen_work_item_keys: [...runState.seen_work_item_keys],
118
+ completed_work_item_keys: [...ledger.completed_work_item_keys],
119
+ last_completed_work_item: ledger.last_completed_work_item,
52
120
  phase_results: {},
53
121
  phase_visit_counts: {}
54
122
  };
55
123
  const iterationStateFile = path.join(iterationDir, "state.json");
56
124
  await writeJson(iterationStateFile, iterationState);
57
- let currentPhaseId = config.workflow.entry_phase;
125
+ let currentPhaseId = selection.nextPhaseId;
58
126
  while (currentPhaseId) {
59
127
  const phase = phasesById.get(currentPhaseId);
60
128
  if (!phase) {
61
129
  throw new Error(`Unknown phase "${currentPhaseId}"`);
62
130
  }
63
- const visits = (iterationState.phase_visit_counts[phase.id] ?? 0) + 1;
64
- iterationState.phase_visit_counts[phase.id] = visits;
65
- if (visits > phase.max_visits_per_iteration) {
66
- iterationState.status = "failed";
67
- runState.status = "failed";
68
- await writeJson(iterationStateFile, iterationState);
69
- await writeJson(path.join(runDir, "state.json"), runState);
70
- throw new Error(`Phase "${phase.id}" exceeded max_visits_per_iteration=${phase.max_visits_per_iteration}`);
71
- }
72
- process.stdout.write(`[${iterationNumber}/${config.workflow.max_iterations}] ${phase.id} ... `);
73
- await writeJson(iterationStateFile, iterationState);
74
- const phaseResult = await runPhase({
131
+ const phaseResult = await runWorkflowPhase({
75
132
  projectRoot,
76
133
  runDir,
77
134
  iterationDir,
78
- stateFile: iterationStateFile,
135
+ iterationStateFile,
79
136
  config,
80
137
  phase,
81
- state: iterationState
138
+ iterationState,
139
+ runState,
140
+ iterationNumber,
141
+ maxIterations: selectedWorkItems.length
82
142
  });
83
143
  if (!phaseResult.ok) {
84
- console.log(pc.red("failed"));
85
- iterationState.status = "failed";
86
- iterationState.phase_results[phase.id] = {
87
- status: "failed",
88
- error: phaseResult.error
89
- };
90
- runState.status = "failed";
91
- await writeJson(iterationStateFile, iterationState);
92
- await writeJson(path.join(runDir, "state.json"), runState);
93
144
  throw new Error(phaseResult.error);
94
145
  }
95
- iterationState.phase_results[phase.id] =
96
- phaseResult.result ?? { status: "ok" };
97
- const workItem = readObjectProperty(phaseResult.result, "work_item");
98
- if (workItem !== undefined) {
99
- iterationState.work_item = workItem;
100
- const key = readObjectProperty(workItem, "key");
101
- if (typeof key === "string" && !runState.seen_work_item_keys.includes(key)) {
102
- runState.seen_work_item_keys.push(key);
103
- iterationState.seen_work_item_keys = [...runState.seen_work_item_keys];
104
- }
105
- }
106
146
  const nextTarget = resolveNextTarget(phase, phaseResult.outcome);
107
- const label = phaseResult.outcome ?? "ok";
108
- console.log(pc.green(label));
109
- await writeJson(iterationStateFile, iterationState);
110
- await writeJson(path.join(runDir, "state.json"), runState);
111
147
  if (nextTarget === "stop_run") {
112
- iterationState.status = phaseResult.outcome === "no_work" ? "no_work" : "stopped";
148
+ iterationState.status =
149
+ phaseResult.outcome === "no_work" ? "no_work" : "stopped";
113
150
  runState.status =
114
151
  phaseResult.outcome === "no_work" ? "completed_no_work" : "stopped";
115
152
  await writeJson(iterationStateFile, iterationState);
@@ -122,6 +159,16 @@ export async function runWorkflow(options) {
122
159
  if (nextTarget === "stop_iteration" || nextTarget === "next_iteration") {
123
160
  iterationState.status = "completed";
124
161
  runState.completed_iterations += 1;
162
+ ledger = await completeIterationWorkItem({
163
+ nyxDir,
164
+ ledger,
165
+ runState,
166
+ iterationState
167
+ });
168
+ iterationState.completed_work_item_keys = [
169
+ ...ledger.completed_work_item_keys
170
+ ];
171
+ iterationState.last_completed_work_item = ledger.last_completed_work_item;
125
172
  await writeJson(iterationStateFile, iterationState);
126
173
  await writeJson(path.join(runDir, "state.json"), runState);
127
174
  break;
@@ -129,12 +176,203 @@ export async function runWorkflow(options) {
129
176
  currentPhaseId = nextTarget;
130
177
  }
131
178
  }
132
- runState.status = "completed_max_iterations";
179
+ runState.status =
180
+ selectedWorkItems.length >= config.workflow.max_iterations
181
+ ? "completed_max_iterations"
182
+ : "completed_queue";
133
183
  await writeJson(path.join(runDir, "state.json"), runState);
134
184
  console.log("");
135
185
  console.log(`Done: ${runState.status}`);
136
186
  console.log(`Run dir: ${relativeToProject(projectRoot, runDir)}`);
137
187
  }
188
+ async function runSelectionPhase(input) {
189
+ const selectionPhase = input.phasesById.get(input.config.workflow.entry_phase);
190
+ if (!selectionPhase) {
191
+ throw new Error(`Unknown phase "${input.config.workflow.entry_phase}"`);
192
+ }
193
+ const availableWorkItems = await loadAvailableWorkItems({
194
+ projectRoot: input.projectRoot,
195
+ config: input.config,
196
+ runState: input.runState,
197
+ ledger: input.ledger
198
+ });
199
+ const selectionDir = path.join(input.runDir, "selection");
200
+ await ensureDir(selectionDir);
201
+ const selectionState = {
202
+ iteration: 0,
203
+ status: "running",
204
+ available_work_items: availableWorkItems,
205
+ selected_work_item_queue: [],
206
+ seen_work_item_keys: [...input.runState.seen_work_item_keys],
207
+ completed_work_item_keys: [...input.ledger.completed_work_item_keys],
208
+ last_completed_work_item: input.ledger.last_completed_work_item,
209
+ phase_results: {},
210
+ phase_visit_counts: {}
211
+ };
212
+ const selectionStateFile = path.join(selectionDir, "state.json");
213
+ await writeJson(selectionStateFile, selectionState);
214
+ const phaseResult = await runWorkflowPhase({
215
+ projectRoot: input.projectRoot,
216
+ runDir: input.runDir,
217
+ iterationDir: selectionDir,
218
+ iterationStateFile: selectionStateFile,
219
+ config: input.config,
220
+ phase: selectionPhase,
221
+ iterationState: selectionState,
222
+ runState: input.runState,
223
+ iterationNumber: "selection",
224
+ maxIterations: input.config.workflow.max_iterations
225
+ });
226
+ if (!phaseResult.ok) {
227
+ throw new Error(phaseResult.error);
228
+ }
229
+ const nextTarget = resolveNextTarget(selectionPhase, phaseResult.outcome);
230
+ if (nextTarget === "stop_run") {
231
+ selectionState.status =
232
+ phaseResult.outcome === "no_work" ? "no_work" : "stopped";
233
+ input.runState.status =
234
+ phaseResult.outcome === "no_work" ? "completed_no_work" : "stopped";
235
+ await writeJson(selectionStateFile, selectionState);
236
+ await writeJson(path.join(input.runDir, "state.json"), input.runState);
237
+ console.log("");
238
+ console.log(`Done: ${input.runState.status}`);
239
+ console.log(`Run dir: ${relativeToProject(input.projectRoot, input.runDir)}`);
240
+ return { status: "stopped" };
241
+ }
242
+ if (!nextTarget || isReservedTarget(nextTarget)) {
243
+ throw new Error(`Selection phase must transition to a runnable phase, got "${nextTarget ?? "none"}"`);
244
+ }
245
+ const workItems = readSelectedWorkItems({
246
+ config: input.config,
247
+ result: phaseResult.result,
248
+ availableWorkItems,
249
+ seenWorkItemKeys: selectionState.seen_work_item_keys,
250
+ completedWorkItemKeys: selectionState.completed_work_item_keys
251
+ });
252
+ const selectionGroups = readSelectionGroups(phaseResult.result);
253
+ selectionState.status = "completed";
254
+ selectionState.selected_work_item_queue = workItems;
255
+ await writeJson(selectionStateFile, selectionState);
256
+ await writeJson(path.join(input.runDir, "state.json"), input.runState);
257
+ return {
258
+ status: "selected",
259
+ workItems,
260
+ selectionGroups,
261
+ availableWorkItems,
262
+ nextPhaseId: nextTarget
263
+ };
264
+ }
265
+ async function runWorkflowPhase(input) {
266
+ const visits = (input.iterationState.phase_visit_counts[input.phase.id] ?? 0) + 1;
267
+ input.iterationState.phase_visit_counts[input.phase.id] = visits;
268
+ if (visits > input.phase.max_visits_per_iteration) {
269
+ input.iterationState.status = "failed";
270
+ input.runState.status = "failed";
271
+ await writeJson(input.iterationStateFile, input.iterationState);
272
+ await writeJson(path.join(input.runDir, "state.json"), input.runState);
273
+ return {
274
+ ok: false,
275
+ error: `Phase "${input.phase.id}" exceeded max_visits_per_iteration=${input.phase.max_visits_per_iteration}`
276
+ };
277
+ }
278
+ process.stdout.write(`[${input.iterationNumber}/${input.maxIterations}] ${input.phase.id} ... `);
279
+ await writeJson(input.iterationStateFile, input.iterationState);
280
+ const phaseResult = await runPhase({
281
+ projectRoot: input.projectRoot,
282
+ runDir: input.runDir,
283
+ iterationDir: input.iterationDir,
284
+ stateFile: input.iterationStateFile,
285
+ config: input.config,
286
+ phase: input.phase,
287
+ state: input.iterationState
288
+ });
289
+ if (!phaseResult.ok) {
290
+ console.log(pc.red("failed"));
291
+ input.iterationState.status = "failed";
292
+ input.iterationState.phase_results[input.phase.id] = {
293
+ status: "failed",
294
+ error: phaseResult.error
295
+ };
296
+ input.runState.status = "failed";
297
+ await writeJson(input.iterationStateFile, input.iterationState);
298
+ await writeJson(path.join(input.runDir, "state.json"), input.runState);
299
+ return phaseResult;
300
+ }
301
+ input.iterationState.phase_results[input.phase.id] =
302
+ phaseResult.result ?? { status: "ok" };
303
+ const workItem = readObjectProperty(phaseResult.result, "work_item");
304
+ if (workItem !== undefined && input.iterationNumber !== "selection") {
305
+ input.iterationState.work_item = workItem;
306
+ const key = readObjectProperty(workItem, "key");
307
+ if (typeof key === "string" &&
308
+ !input.runState.seen_work_item_keys.includes(key)) {
309
+ input.runState.seen_work_item_keys.push(key);
310
+ input.iterationState.seen_work_item_keys = [
311
+ ...input.runState.seen_work_item_keys
312
+ ];
313
+ }
314
+ }
315
+ const label = phaseResult.outcome ?? "ok";
316
+ console.log(pc.green(label));
317
+ await writeJson(input.iterationStateFile, input.iterationState);
318
+ await writeJson(path.join(input.runDir, "state.json"), input.runState);
319
+ return phaseResult;
320
+ }
321
+ export async function confirmWorkItemsWithCheckbox(input) {
322
+ if (input.workItems.length === 0) {
323
+ return [];
324
+ }
325
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
326
+ throw new Error("Work item selection confirmation requires an interactive TTY");
327
+ }
328
+ const selectedKeys = await checkbox({
329
+ message: `Confirm work items to run (max ${input.maxIterations})`,
330
+ choices: buildConfirmationChoices(input),
331
+ required: false,
332
+ pageSize: Math.min(Math.max(input.workItems.length + 2, 5), 20)
333
+ });
334
+ const selected = new Set(selectedKeys);
335
+ return input.workItems.filter((item) => selected.has(item.key));
336
+ }
337
+ function buildConfirmationChoices(input) {
338
+ const choices = [];
339
+ const byKey = new Map(input.workItems.map((item) => [item.key, item]));
340
+ const displayed = new Set();
341
+ for (const group of input.selectionGroups) {
342
+ const groupItems = group.work_item_keys
343
+ .map((key) => byKey.get(key))
344
+ .filter((item) => {
345
+ if (!item) {
346
+ return false;
347
+ }
348
+ return !displayed.has(item.key);
349
+ });
350
+ if (groupItems.length === 0) {
351
+ continue;
352
+ }
353
+ choices.push(new Separator(`-- ${group.title} --`));
354
+ for (const item of groupItems) {
355
+ choices.push(buildConfirmationChoice(item));
356
+ displayed.add(item.key);
357
+ }
358
+ }
359
+ const ungroupedItems = input.workItems.filter((item) => !displayed.has(item.key));
360
+ if (input.selectionGroups.length > 0 && ungroupedItems.length > 0) {
361
+ choices.push(new Separator("-- Ungrouped --"));
362
+ }
363
+ for (const item of ungroupedItems) {
364
+ choices.push(buildConfirmationChoice(item));
365
+ }
366
+ return choices;
367
+ }
368
+ function buildConfirmationChoice(item) {
369
+ return {
370
+ value: item.key,
371
+ name: `${item.title} (${item.key})`,
372
+ description: item.excerpt,
373
+ checked: true
374
+ };
375
+ }
138
376
  function resolveNextTarget(phase, outcome) {
139
377
  if (phase.transitions) {
140
378
  if (!outcome) {
@@ -148,6 +386,97 @@ function resolveNextTarget(phase, outcome) {
148
386
  }
149
387
  return phase.next;
150
388
  }
389
+ async function loadAvailableWorkItems(input) {
390
+ const candidates = await listWorkItemCandidates({
391
+ projectRoot: input.projectRoot,
392
+ config: input.config
393
+ });
394
+ return filterAvailableWorkItems({
395
+ candidates,
396
+ seenWorkItemKeys: input.runState.seen_work_item_keys,
397
+ completedWorkItemKeys: input.ledger.completed_work_item_keys
398
+ });
399
+ }
400
+ async function completeIterationWorkItem(input) {
401
+ const ledger = markWorkItemCompleted({
402
+ ledger: input.ledger,
403
+ workItem: input.iterationState.work_item
404
+ });
405
+ input.runState.completed_work_item_keys = [
406
+ ...ledger.completed_work_item_keys
407
+ ];
408
+ input.runState.last_completed_work_item = ledger.last_completed_work_item;
409
+ await writeWorkItemLedger(input.nyxDir, ledger);
410
+ return ledger;
411
+ }
412
+ function readSelectedWorkItems(input) {
413
+ const workItems = readObjectProperty(input.result, "work_items");
414
+ const legacyWorkItem = readObjectProperty(input.result, "work_item");
415
+ const selection = workItems ?? (legacyWorkItem === undefined ? undefined : [legacyWorkItem]);
416
+ if (selection === undefined) {
417
+ throw new Error('Selection phase returned "selected" without work_items');
418
+ }
419
+ const validation = validateWorkItemQueue({
420
+ config: input.config,
421
+ workItems: selection,
422
+ availableWorkItems: input.availableWorkItems,
423
+ seenWorkItemKeys: input.seenWorkItemKeys,
424
+ completedWorkItemKeys: input.completedWorkItemKeys
425
+ });
426
+ if (!validation.ok) {
427
+ throw new Error(validation.error);
428
+ }
429
+ return validation.workItems;
430
+ }
431
+ function normalizeConfirmedWorkItems(input) {
432
+ const queuedByKey = new Map(input.queue.map((item) => [item.key, item]));
433
+ const selected = new Set();
434
+ const normalized = [];
435
+ for (const item of input.confirmed) {
436
+ const queued = queuedByKey.get(item.key);
437
+ if (!queued) {
438
+ throw new Error(`Confirmed work item key "${item.key}" is not in selected queue`);
439
+ }
440
+ if (selected.has(item.key)) {
441
+ throw new Error(`Confirmed work item key "${item.key}" was selected twice`);
442
+ }
443
+ selected.add(item.key);
444
+ normalized.push(queued);
445
+ }
446
+ return normalized;
447
+ }
448
+ function readSelectionGroups(result) {
449
+ const groups = readObjectProperty(result, "selection_groups");
450
+ if (!Array.isArray(groups)) {
451
+ return [];
452
+ }
453
+ return groups
454
+ .map((group) => {
455
+ if (!isRecord(group) || typeof group.title !== "string") {
456
+ return undefined;
457
+ }
458
+ const keys = Array.isArray(group.work_item_keys)
459
+ ? group.work_item_keys.filter((key) => typeof key === "string")
460
+ : [];
461
+ if (keys.length === 0) {
462
+ return undefined;
463
+ }
464
+ const normalized = {
465
+ title: group.title,
466
+ work_item_keys: keys
467
+ };
468
+ if (typeof group.kind === "string") {
469
+ normalized.kind = group.kind;
470
+ }
471
+ return normalized;
472
+ })
473
+ .filter((group) => Boolean(group));
474
+ }
475
+ function isReservedTarget(target) {
476
+ return (target === "stop_run" ||
477
+ target === "stop_iteration" ||
478
+ target === "next_iteration");
479
+ }
151
480
  function readObjectProperty(value, key) {
152
481
  if (value &&
153
482
  typeof value === "object" &&
@@ -156,3 +485,6 @@ function readObjectProperty(value, key) {
156
485
  }
157
486
  return undefined;
158
487
  }
488
+ function isRecord(value) {
489
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
490
+ }