@nathapp/nax 0.23.0 → 0.24.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/bin/nax.ts +20 -2
- package/nax/features/central-run-registry/prd.json +105 -0
- package/package.json +2 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/logs.ts +87 -17
- package/src/commands/runs.ts +220 -0
- package/src/execution/sequential-executor.ts +4 -0
- package/src/pipeline/subscribers/events-writer.ts +121 -0
- package/src/pipeline/subscribers/registry.ts +73 -0
- package/test/integration/cli/cli-logs.test.ts +40 -17
- package/test/unit/commands/logs.test.ts +63 -22
- package/test/unit/commands/runs.test.ts +303 -0
- package/test/unit/pipeline/subscribers/events-writer.test.ts +227 -0
- package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
package/bin/nax.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { generateCommand } from "../src/cli/generate";
|
|
|
62
62
|
import { diagnose } from "../src/commands/diagnose";
|
|
63
63
|
import { logsCommand } from "../src/commands/logs";
|
|
64
64
|
import { precheckCommand } from "../src/commands/precheck";
|
|
65
|
+
import { runsCommand } from "../src/commands/runs";
|
|
65
66
|
import { unlockCommand } from "../src/commands/unlock";
|
|
66
67
|
import { DEFAULT_CONFIG, findProjectDir, loadConfig, validateDirectory } from "../src/config";
|
|
67
68
|
import { run } from "../src/execution";
|
|
@@ -685,7 +686,7 @@ program
|
|
|
685
686
|
.option("-s, --story <id>", "Filter to specific story")
|
|
686
687
|
.option("--level <level>", "Filter by log level (debug|info|warn|error)")
|
|
687
688
|
.option("-l, --list", "List all runs in table format", false)
|
|
688
|
-
.option("-r, --run <
|
|
689
|
+
.option("-r, --run <runId>", "Select run by run ID from central registry (global)")
|
|
689
690
|
.option("-j, --json", "Output raw JSONL", false)
|
|
690
691
|
.action(async (options) => {
|
|
691
692
|
let workdir: string;
|
|
@@ -773,7 +774,24 @@ program
|
|
|
773
774
|
});
|
|
774
775
|
|
|
775
776
|
// ── runs ─────────────────────────────────────────────
|
|
776
|
-
const runs = program
|
|
777
|
+
const runs = program
|
|
778
|
+
.command("runs")
|
|
779
|
+
.description("Show all registered runs from the central registry (~/.nax/runs/)")
|
|
780
|
+
.option("--project <name>", "Filter by project name")
|
|
781
|
+
.option("--last <N>", "Limit to N most recent runs (default: 20)")
|
|
782
|
+
.option("--status <status>", "Filter by status (running|completed|failed|crashed)")
|
|
783
|
+
.action(async (options) => {
|
|
784
|
+
try {
|
|
785
|
+
await runsCommand({
|
|
786
|
+
project: options.project,
|
|
787
|
+
last: options.last !== undefined ? Number.parseInt(options.last, 10) : undefined,
|
|
788
|
+
status: options.status,
|
|
789
|
+
});
|
|
790
|
+
} catch (err) {
|
|
791
|
+
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
|
792
|
+
process.exit(1);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
777
795
|
|
|
778
796
|
runs
|
|
779
797
|
.command("list")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"project": "nax",
|
|
3
|
+
"branchName": "feat/central-run-registry",
|
|
4
|
+
"feature": "central-run-registry",
|
|
5
|
+
"version": "0.24.0",
|
|
6
|
+
"description": "Global run index across all projects. Events file writer for machine-readable lifecycle events, registry subscriber that indexes every run to ~/.nax/runs/, nax runs CLI for cross-project run history, and nax logs --run <runId> for global log resolution.",
|
|
7
|
+
"userStories": [
|
|
8
|
+
{
|
|
9
|
+
"id": "CRR-000",
|
|
10
|
+
"title": "Events file writer subscriber",
|
|
11
|
+
"description": "New subscriber: src/pipeline/subscribers/events-writer.ts \u2014 wireEventsWriter(bus, project, feature, runId, workdir). Listens to run:started, story:started, story:completed, story:failed, run:completed, run:paused. Writes one JSON line per event to ~/.nax/events/<project>/events.jsonl (append mode). Each line: {\"ts\": ISO8601, \"event\": string, \"runId\": string, \"feature\": string, \"project\": string, \"storyId\"?: string, \"data\"?: object}. The run:completed event writes an 'on-complete' entry \u2014 the machine-readable signal that nax exited gracefully (fixes watchdog false crash reports). Best-effort: wrap all writes in try/catch, log warnings on failure, never throw or block the pipeline. Create ~/.nax/events/<project>/ on first write via mkdir recursive. Derive project name from path.basename(workdir). Wire in sequential-executor.ts alongside existing wireHooks/wireReporters/wireInteraction calls. Return UnsubscribeFn matching existing subscriber pattern.",
|
|
12
|
+
"complexity": "medium",
|
|
13
|
+
"status": "pending",
|
|
14
|
+
"acceptanceCriteria": [
|
|
15
|
+
"After a run, ~/.nax/events/<project>/events.jsonl exists with JSONL entries",
|
|
16
|
+
"Each line is valid JSON with ts, event, runId, feature, project fields",
|
|
17
|
+
"run:completed produces an entry with event=on-complete",
|
|
18
|
+
"story:started/completed/failed events include storyId",
|
|
19
|
+
"Write failure does not crash or block the nax run",
|
|
20
|
+
"Directory is created automatically on first write",
|
|
21
|
+
"wireEventsWriter is called in sequential-executor.ts",
|
|
22
|
+
"Returns UnsubscribeFn consistent with wireHooks/wireReporters pattern"
|
|
23
|
+
],
|
|
24
|
+
"attempts": 0,
|
|
25
|
+
"priorErrors": [],
|
|
26
|
+
"priorFailures": [],
|
|
27
|
+
"escalations": [],
|
|
28
|
+
"dependencies": [],
|
|
29
|
+
"tags": [],
|
|
30
|
+
"storyPoints": 2
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "CRR-001",
|
|
34
|
+
"title": "Registry writer subscriber",
|
|
35
|
+
"description": "New subscriber: src/pipeline/subscribers/registry.ts \u2014 wireRegistry(bus, project, feature, runId, workdir). Listens to run:started. On event, creates ~/.nax/runs/<project>-<feature>-<runId>/meta.json with fields: {runId, project, feature, workdir, statusPath: join(workdir, 'nax/features', feature, 'status.json'), eventsDir: join(workdir, 'nax/features', feature, 'runs'), registeredAt: ISO8601}. meta.json schema exported as MetaJson interface. Written once, never updated. Best-effort: try/catch + warn log, never throw/block. Create ~/.nax/runs/ directory on first call via mkdir recursive. Derive project from path.basename(workdir). Wire in sequential-executor.ts alongside wireEventsWriter. Return UnsubscribeFn.",
|
|
36
|
+
"complexity": "medium",
|
|
37
|
+
"status": "pending",
|
|
38
|
+
"acceptanceCriteria": [
|
|
39
|
+
"After run start, ~/.nax/runs/<project>-<feature>-<runId>/meta.json exists",
|
|
40
|
+
"meta.json contains runId, project, feature, workdir, statusPath, eventsDir, registeredAt",
|
|
41
|
+
"statusPath points to <workdir>/nax/features/<feature>/status.json",
|
|
42
|
+
"eventsDir points to <workdir>/nax/features/<feature>/runs",
|
|
43
|
+
"MetaJson interface is exported from the module",
|
|
44
|
+
"Write failure does not crash or block the nax run",
|
|
45
|
+
"wireRegistry is called in sequential-executor.ts"
|
|
46
|
+
],
|
|
47
|
+
"attempts": 0,
|
|
48
|
+
"priorErrors": [],
|
|
49
|
+
"priorFailures": [],
|
|
50
|
+
"escalations": [],
|
|
51
|
+
"dependencies": [],
|
|
52
|
+
"tags": [],
|
|
53
|
+
"storyPoints": 2
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "CRR-002",
|
|
57
|
+
"title": "nax runs CLI command",
|
|
58
|
+
"description": "New command: src/commands/runs.ts \u2014 runsCommand(options). Reads all ~/.nax/runs/*/meta.json, resolves each statusPath to read the live NaxStatusFile for current state. Displays a table sorted by registeredAt desc. Columns: RUN ID, PROJECT, FEATURE, STATUS, STORIES (passed/total), DURATION, DATE. Default limit: 20. Options: --project <name> (filter), --last <N> (limit), --status <running|completed|failed|crashed> (filter). If statusPath missing, show status as '[unavailable]'. Register in src/commands/index.ts and wire in bin/nax.ts CLI arg parser (match pattern of existing diagnose/logs/status commands). Use chalk for colored output. Green for completed, red for failed, yellow for running, dim for unavailable.",
|
|
59
|
+
"complexity": "medium",
|
|
60
|
+
"status": "pending",
|
|
61
|
+
"acceptanceCriteria": [
|
|
62
|
+
"nax runs displays a table of all registered runs sorted newest-first",
|
|
63
|
+
"nax runs --project <name> filters by project name",
|
|
64
|
+
"nax runs --last <N> limits output to N runs",
|
|
65
|
+
"nax runs --status <status> filters by run status",
|
|
66
|
+
"Runs with missing statusPath show '[unavailable]' status gracefully",
|
|
67
|
+
"Empty registry shows 'No runs found' message",
|
|
68
|
+
"Command is registered in CLI help output and bin/nax.ts"
|
|
69
|
+
],
|
|
70
|
+
"attempts": 0,
|
|
71
|
+
"priorErrors": [],
|
|
72
|
+
"priorFailures": [],
|
|
73
|
+
"escalations": [],
|
|
74
|
+
"dependencies": [
|
|
75
|
+
"CRR-001"
|
|
76
|
+
],
|
|
77
|
+
"tags": [],
|
|
78
|
+
"storyPoints": 3
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "CRR-003",
|
|
82
|
+
"title": "nax logs --run <runId> global resolution",
|
|
83
|
+
"description": "Enhance src/commands/logs.ts logsCommand: when --run <runId> is provided, scan ~/.nax/runs/*/meta.json for matching runId (exact or prefix match). Read eventsDir from meta.json, locate the JSONL log file in that directory (newest .jsonl file), then display using existing displayLogs/followLogs functions. Falls back to current behavior (local feature context) when --run is not specified. If runId not found in registry, throw error Run not found in registry: <runId>. If eventsDir or log file missing, show unavailable message. Update LogsOptions interface to accept run?: string. Update bin/nax.ts to pass --run option through.",
|
|
84
|
+
"complexity": "medium",
|
|
85
|
+
"status": "pending",
|
|
86
|
+
"acceptanceCriteria": [
|
|
87
|
+
"nax logs --run <runId> displays logs from the matching run regardless of cwd",
|
|
88
|
+
"Resolution uses ~/.nax/runs/*/meta.json to find eventsDir path",
|
|
89
|
+
"Unknown runId shows clear error message",
|
|
90
|
+
"Missing log files show unavailable message",
|
|
91
|
+
"Without --run flag, existing local behavior is unchanged",
|
|
92
|
+
"Partial runId matching works (prefix match on runId field)"
|
|
93
|
+
],
|
|
94
|
+
"attempts": 0,
|
|
95
|
+
"priorErrors": [],
|
|
96
|
+
"priorFailures": [],
|
|
97
|
+
"escalations": [],
|
|
98
|
+
"dependencies": [
|
|
99
|
+
"CRR-001"
|
|
100
|
+
],
|
|
101
|
+
"tags": [],
|
|
102
|
+
"storyPoints": 2
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AI Coding Agent Orchestrator
|
|
3
|
+
"version": "0.24.0",
|
|
4
|
+
"description": "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"nax": "./bin/nax.ts"
|
package/src/commands/index.ts
CHANGED
|
@@ -5,4 +5,5 @@
|
|
|
5
5
|
export { resolveProject, type ResolveProjectOptions, type ResolvedProject } from "./common";
|
|
6
6
|
export { logsCommand, type LogsOptions } from "./logs";
|
|
7
7
|
export { precheckCommand, type PrecheckOptions } from "./precheck";
|
|
8
|
+
export { runsCommand, type RunsOptions } from "./runs";
|
|
8
9
|
export { unlockCommand, type UnlockOptions } from "./unlock";
|
package/src/commands/logs.ts
CHANGED
|
@@ -6,13 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, readdirSync } from "node:fs";
|
|
9
|
+
import { readdir } from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
9
11
|
import { join } from "node:path";
|
|
10
12
|
import chalk from "chalk";
|
|
11
13
|
import type { LogEntry, LogLevel } from "../logger/types";
|
|
12
14
|
import { type FormattedEntry, formatLogEntry, formatRunSummary } from "../logging/formatter";
|
|
13
15
|
import type { RunSummary, VerbosityMode } from "../logging/types";
|
|
16
|
+
import type { MetaJson } from "../pipeline/subscribers/registry";
|
|
14
17
|
import { type ResolveProjectOptions, resolveProject } from "./common";
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Swappable dependencies for testing (project convention: _deps over mock.module).
|
|
21
|
+
*/
|
|
22
|
+
export const _deps = {
|
|
23
|
+
getRunsDir: () => process.env.NAX_RUNS_DIR ?? join(homedir(), ".nax", "runs"),
|
|
24
|
+
};
|
|
25
|
+
|
|
16
26
|
/**
|
|
17
27
|
* Options for logs command
|
|
18
28
|
*/
|
|
@@ -43,12 +53,84 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
|
43
53
|
error: 3,
|
|
44
54
|
};
|
|
45
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Resolve log file path for a runId from the central registry (~/.nax/runs/).
|
|
58
|
+
*
|
|
59
|
+
* Scans all ~/.nax/runs/*\/meta.json entries for an exact or prefix match on runId.
|
|
60
|
+
* Returns the path to the matching run's JSONL file, or null if eventsDir/file is unavailable.
|
|
61
|
+
* Throws if the runId is not found in the registry at all.
|
|
62
|
+
*
|
|
63
|
+
* @param runId - Full or prefix run ID to look up
|
|
64
|
+
* @returns Absolute path to the JSONL log file, or null if unavailable
|
|
65
|
+
*/
|
|
66
|
+
async function resolveRunFileFromRegistry(runId: string): Promise<string | null> {
|
|
67
|
+
const runsDir = _deps.getRunsDir();
|
|
68
|
+
|
|
69
|
+
let entries: string[];
|
|
70
|
+
try {
|
|
71
|
+
entries = await readdir(runsDir);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error(`Run not found in registry: ${runId}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let matched: MetaJson | null = null;
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const metaPath = join(runsDir, entry, "meta.json");
|
|
79
|
+
try {
|
|
80
|
+
const meta: MetaJson = await Bun.file(metaPath).json();
|
|
81
|
+
if (meta.runId === runId || meta.runId.startsWith(runId)) {
|
|
82
|
+
matched = meta;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// skip unreadable meta.json entries
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!matched) {
|
|
91
|
+
throw new Error(`Run not found in registry: ${runId}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!existsSync(matched.eventsDir)) {
|
|
95
|
+
console.log(`Log directory unavailable for run: ${runId}`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const files = readdirSync(matched.eventsDir)
|
|
100
|
+
.filter((f) => f.endsWith(".jsonl") && f !== "latest.jsonl")
|
|
101
|
+
.sort()
|
|
102
|
+
.reverse();
|
|
103
|
+
|
|
104
|
+
if (files.length === 0) {
|
|
105
|
+
console.log(`No log files found for run: ${runId}`);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Look for the specific run file by runId, fall back to newest
|
|
110
|
+
const specificFile = files.find((f) => f === `${matched.runId}.jsonl`);
|
|
111
|
+
return join(matched.eventsDir, specificFile ?? files[0]);
|
|
112
|
+
}
|
|
113
|
+
|
|
46
114
|
/**
|
|
47
115
|
* Display logs with filtering and formatting
|
|
48
116
|
*
|
|
49
117
|
* @param options - Command options
|
|
50
118
|
*/
|
|
51
119
|
export async function logsCommand(options: LogsOptions): Promise<void> {
|
|
120
|
+
// When --run <runId> is provided, resolve via central registry
|
|
121
|
+
if (options.run) {
|
|
122
|
+
const runFile = await resolveRunFileFromRegistry(options.run);
|
|
123
|
+
if (!runFile) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (options.follow) {
|
|
127
|
+
await followLogs(runFile, options);
|
|
128
|
+
} else {
|
|
129
|
+
await displayLogs(runFile, options);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
52
134
|
// Resolve project directory
|
|
53
135
|
const resolved = resolveProject({ dir: options.dir });
|
|
54
136
|
const naxDir = join(resolved.projectDir, "nax");
|
|
@@ -77,8 +159,8 @@ export async function logsCommand(options: LogsOptions): Promise<void> {
|
|
|
77
159
|
return;
|
|
78
160
|
}
|
|
79
161
|
|
|
80
|
-
// Determine which run to display
|
|
81
|
-
const runFile = await selectRunFile(runsDir
|
|
162
|
+
// Determine which run to display (latest by default — --run handled above via registry)
|
|
163
|
+
const runFile = await selectRunFile(runsDir);
|
|
82
164
|
|
|
83
165
|
if (!runFile) {
|
|
84
166
|
throw new Error("No runs found for this feature");
|
|
@@ -95,9 +177,9 @@ export async function logsCommand(options: LogsOptions): Promise<void> {
|
|
|
95
177
|
}
|
|
96
178
|
|
|
97
179
|
/**
|
|
98
|
-
* Select which run file to display
|
|
180
|
+
* Select which run file to display (always returns the latest run)
|
|
99
181
|
*/
|
|
100
|
-
async function selectRunFile(runsDir: string
|
|
182
|
+
async function selectRunFile(runsDir: string): Promise<string | null> {
|
|
101
183
|
const files = readdirSync(runsDir)
|
|
102
184
|
.filter((f) => f.endsWith(".jsonl") && f !== "latest.jsonl")
|
|
103
185
|
.sort()
|
|
@@ -107,19 +189,7 @@ async function selectRunFile(runsDir: string, runTimestamp?: string): Promise<st
|
|
|
107
189
|
return null;
|
|
108
190
|
}
|
|
109
191
|
|
|
110
|
-
|
|
111
|
-
if (!runTimestamp) {
|
|
112
|
-
return join(runsDir, files[0]);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Find matching run by partial timestamp
|
|
116
|
-
const matchingFile = files.find((f) => f.startsWith(runTimestamp));
|
|
117
|
-
|
|
118
|
-
if (!matchingFile) {
|
|
119
|
-
throw new Error(`Run not found: ${runTimestamp}`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return join(runsDir, matchingFile);
|
|
192
|
+
return join(runsDir, files[0]);
|
|
123
193
|
}
|
|
124
194
|
|
|
125
195
|
/**
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs Command
|
|
3
|
+
*
|
|
4
|
+
* Reads all ~/.nax/runs/*\/meta.json entries and displays a table of runs
|
|
5
|
+
* sorted by registeredAt descending. Resolves each statusPath to read the
|
|
6
|
+
* live NaxStatusFile for current state. Falls back to '[unavailable]' if
|
|
7
|
+
* the status file is missing or unreadable.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* nax runs [--project <name>] [--last <N>] [--status <status>]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdir } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import type { NaxStatusFile } from "../execution/status-file";
|
|
18
|
+
import type { MetaJson } from "../pipeline/subscribers/registry";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LIMIT = 20;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Swappable dependencies for testing (project convention: _deps over mock.module).
|
|
24
|
+
*/
|
|
25
|
+
export const _deps = {
|
|
26
|
+
getRunsDir: () => join(homedir(), ".nax", "runs"),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface RunsOptions {
|
|
30
|
+
/** Filter by project name */
|
|
31
|
+
project?: string;
|
|
32
|
+
/** Limit number of runs displayed (default: 20) */
|
|
33
|
+
last?: number;
|
|
34
|
+
/** Filter by run status */
|
|
35
|
+
status?: "running" | "completed" | "failed" | "crashed";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RunRow {
|
|
39
|
+
runId: string;
|
|
40
|
+
project: string;
|
|
41
|
+
feature: string;
|
|
42
|
+
status: string;
|
|
43
|
+
passed: number;
|
|
44
|
+
total: number;
|
|
45
|
+
durationMs: number;
|
|
46
|
+
registeredAt: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format duration in milliseconds to human-readable string.
|
|
51
|
+
*/
|
|
52
|
+
function formatDuration(ms: number): string {
|
|
53
|
+
if (ms <= 0) return "-";
|
|
54
|
+
const minutes = Math.floor(ms / 60000);
|
|
55
|
+
const seconds = Math.floor((ms % 60000) / 1000);
|
|
56
|
+
if (minutes === 0) return `${seconds}s`;
|
|
57
|
+
return `${minutes}m ${seconds}s`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Format ISO date to short local date string.
|
|
62
|
+
*/
|
|
63
|
+
function formatDate(iso: string): string {
|
|
64
|
+
try {
|
|
65
|
+
const d = new Date(iso);
|
|
66
|
+
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
67
|
+
} catch {
|
|
68
|
+
return iso;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Color a status string for terminal output.
|
|
74
|
+
*/
|
|
75
|
+
function colorStatus(status: string): string {
|
|
76
|
+
switch (status) {
|
|
77
|
+
case "completed":
|
|
78
|
+
return chalk.green(status);
|
|
79
|
+
case "failed":
|
|
80
|
+
return chalk.red(status);
|
|
81
|
+
case "running":
|
|
82
|
+
return chalk.yellow(status);
|
|
83
|
+
case "[unavailable]":
|
|
84
|
+
return chalk.dim(status);
|
|
85
|
+
default:
|
|
86
|
+
return chalk.dim(status);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Regex that matches ANSI escape sequences. */
|
|
91
|
+
const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
92
|
+
|
|
93
|
+
/** Strip ANSI escape codes to compute visible string length. */
|
|
94
|
+
function visibleLength(str: string): number {
|
|
95
|
+
return str.replace(ANSI_RE, "").length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pad a string to a fixed width (left-aligned).
|
|
100
|
+
*/
|
|
101
|
+
function pad(str: string, width: number): string {
|
|
102
|
+
const padding = Math.max(0, width - visibleLength(str));
|
|
103
|
+
return str + " ".repeat(padding);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Display all registered runs from ~/.nax/runs/ as a table.
|
|
108
|
+
*
|
|
109
|
+
* @param options - Filter and limit options
|
|
110
|
+
*/
|
|
111
|
+
export async function runsCommand(options: RunsOptions = {}): Promise<void> {
|
|
112
|
+
const runsDir = _deps.getRunsDir();
|
|
113
|
+
|
|
114
|
+
let entries: string[];
|
|
115
|
+
try {
|
|
116
|
+
entries = await readdir(runsDir);
|
|
117
|
+
} catch {
|
|
118
|
+
console.log("No runs found.");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const rows: RunRow[] = [];
|
|
123
|
+
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
const metaPath = join(runsDir, entry, "meta.json");
|
|
126
|
+
let meta: MetaJson;
|
|
127
|
+
try {
|
|
128
|
+
meta = await Bun.file(metaPath).json();
|
|
129
|
+
} catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Apply project filter early
|
|
134
|
+
if (options.project && meta.project !== options.project) continue;
|
|
135
|
+
|
|
136
|
+
// Read live status file
|
|
137
|
+
let statusData: NaxStatusFile | null = null;
|
|
138
|
+
try {
|
|
139
|
+
statusData = await Bun.file(meta.statusPath).json();
|
|
140
|
+
} catch {
|
|
141
|
+
// statusPath missing or unreadable — handled gracefully below
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const runStatus = statusData ? statusData.run.status : "[unavailable]";
|
|
145
|
+
|
|
146
|
+
// Apply status filter
|
|
147
|
+
if (options.status && runStatus !== options.status) continue;
|
|
148
|
+
|
|
149
|
+
rows.push({
|
|
150
|
+
runId: meta.runId,
|
|
151
|
+
project: meta.project,
|
|
152
|
+
feature: meta.feature,
|
|
153
|
+
status: runStatus,
|
|
154
|
+
passed: statusData?.progress.passed ?? 0,
|
|
155
|
+
total: statusData?.progress.total ?? 0,
|
|
156
|
+
durationMs: statusData?.durationMs ?? 0,
|
|
157
|
+
registeredAt: meta.registeredAt,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Sort newest first
|
|
162
|
+
rows.sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime());
|
|
163
|
+
|
|
164
|
+
// Apply limit
|
|
165
|
+
const limit = options.last ?? DEFAULT_LIMIT;
|
|
166
|
+
const displayed = rows.slice(0, limit);
|
|
167
|
+
|
|
168
|
+
if (displayed.length === 0) {
|
|
169
|
+
console.log("No runs found.");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Column widths (minimum per header)
|
|
174
|
+
const COL = {
|
|
175
|
+
runId: Math.max(6, ...displayed.map((r) => r.runId.length)),
|
|
176
|
+
project: Math.max(7, ...displayed.map((r) => r.project.length)),
|
|
177
|
+
feature: Math.max(7, ...displayed.map((r) => r.feature.length)),
|
|
178
|
+
status: Math.max(6, ...displayed.map((r) => visibleLength(r.status))),
|
|
179
|
+
stories: 7,
|
|
180
|
+
duration: 8,
|
|
181
|
+
date: 11,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Header
|
|
185
|
+
const header = [
|
|
186
|
+
pad(chalk.bold("RUN ID"), COL.runId),
|
|
187
|
+
pad(chalk.bold("PROJECT"), COL.project),
|
|
188
|
+
pad(chalk.bold("FEATURE"), COL.feature),
|
|
189
|
+
pad(chalk.bold("STATUS"), COL.status),
|
|
190
|
+
pad(chalk.bold("STORIES"), COL.stories),
|
|
191
|
+
pad(chalk.bold("DURATION"), COL.duration),
|
|
192
|
+
chalk.bold("DATE"),
|
|
193
|
+
].join(" ");
|
|
194
|
+
|
|
195
|
+
console.log();
|
|
196
|
+
console.log(header);
|
|
197
|
+
console.log(
|
|
198
|
+
chalk.dim("-".repeat(COL.runId + COL.project + COL.feature + COL.status + COL.stories + COL.duration + 11 + 12)),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
for (const row of displayed) {
|
|
202
|
+
const colored = colorStatus(row.status);
|
|
203
|
+
const line = [
|
|
204
|
+
pad(row.runId, COL.runId),
|
|
205
|
+
pad(row.project, COL.project),
|
|
206
|
+
pad(row.feature, COL.feature),
|
|
207
|
+
pad(colored, COL.status + (colored.length - visibleLength(colored))),
|
|
208
|
+
pad(`${row.passed}/${row.total}`, COL.stories),
|
|
209
|
+
pad(formatDuration(row.durationMs), COL.duration),
|
|
210
|
+
formatDate(row.registeredAt),
|
|
211
|
+
].join(" ");
|
|
212
|
+
console.log(line);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.log();
|
|
216
|
+
if (rows.length > limit) {
|
|
217
|
+
console.log(chalk.dim(`Showing ${limit} of ${rows.length} runs. Use --last <N> to see more.`));
|
|
218
|
+
console.log();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -5,8 +5,10 @@ import type { StoryMetrics } from "../metrics";
|
|
|
5
5
|
import { pipelineEventBus } from "../pipeline/event-bus";
|
|
6
6
|
import { runPipeline } from "../pipeline/runner";
|
|
7
7
|
import { postRunPipeline } from "../pipeline/stages";
|
|
8
|
+
import { wireEventsWriter } from "../pipeline/subscribers/events-writer";
|
|
8
9
|
import { wireHooks } from "../pipeline/subscribers/hooks";
|
|
9
10
|
import { wireInteraction } from "../pipeline/subscribers/interaction";
|
|
11
|
+
import { wireRegistry } from "../pipeline/subscribers/registry";
|
|
10
12
|
import { wireReporters } from "../pipeline/subscribers/reporters";
|
|
11
13
|
import type { PipelineContext } from "../pipeline/types";
|
|
12
14
|
import { generateHumanHaltSummary, isComplete, isStalled, loadPRD } from "../prd";
|
|
@@ -38,6 +40,8 @@ export async function executeSequential(
|
|
|
38
40
|
wireHooks(pipelineEventBus, ctx.hooks, ctx.workdir, ctx.feature);
|
|
39
41
|
wireReporters(pipelineEventBus, ctx.pluginRegistry, ctx.runId, ctx.startTime);
|
|
40
42
|
wireInteraction(pipelineEventBus, ctx.interactionChain, ctx.config);
|
|
43
|
+
wireEventsWriter(pipelineEventBus, ctx.feature, ctx.runId, ctx.workdir);
|
|
44
|
+
wireRegistry(pipelineEventBus, ctx.feature, ctx.runId, ctx.workdir);
|
|
41
45
|
|
|
42
46
|
const buildResult = (exitReason: SequentialExecutionResult["exitReason"]): SequentialExecutionResult => ({
|
|
43
47
|
prd,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events Writer Subscriber
|
|
3
|
+
*
|
|
4
|
+
* Appends one JSON line per pipeline lifecycle event to
|
|
5
|
+
* ~/.nax/events/<project>/events.jsonl. Provides a machine-readable
|
|
6
|
+
* signal that nax exited gracefully (run:completed → event=on-complete),
|
|
7
|
+
* fixing watchdog false crash reports.
|
|
8
|
+
*
|
|
9
|
+
* Design:
|
|
10
|
+
* - Best-effort: all writes are wrapped in try/catch; never throws or blocks
|
|
11
|
+
* - Directory is created on first write via mkdir recursive
|
|
12
|
+
* - Returns UnsubscribeFn matching wireHooks/wireReporters pattern
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { basename, join } from "node:path";
|
|
18
|
+
import { getSafeLogger } from "../../logger";
|
|
19
|
+
import type { PipelineEventBus } from "../event-bus";
|
|
20
|
+
import type { UnsubscribeFn } from "./hooks";
|
|
21
|
+
|
|
22
|
+
interface EventLine {
|
|
23
|
+
ts: string;
|
|
24
|
+
event: string;
|
|
25
|
+
runId: string;
|
|
26
|
+
feature: string;
|
|
27
|
+
project: string;
|
|
28
|
+
storyId?: string;
|
|
29
|
+
data?: object;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wire events file writer to the pipeline event bus.
|
|
34
|
+
*
|
|
35
|
+
* Listens to run:started, story:started, story:completed, story:failed,
|
|
36
|
+
* run:completed, run:paused and appends one JSONL entry per event.
|
|
37
|
+
*
|
|
38
|
+
* @param bus - The pipeline event bus
|
|
39
|
+
* @param feature - Feature name
|
|
40
|
+
* @param runId - Current run ID
|
|
41
|
+
* @param workdir - Working directory (project name derived via basename)
|
|
42
|
+
* @returns Unsubscribe function
|
|
43
|
+
*/
|
|
44
|
+
export function wireEventsWriter(
|
|
45
|
+
bus: PipelineEventBus,
|
|
46
|
+
feature: string,
|
|
47
|
+
runId: string,
|
|
48
|
+
workdir: string,
|
|
49
|
+
): UnsubscribeFn {
|
|
50
|
+
const logger = getSafeLogger();
|
|
51
|
+
const project = basename(workdir);
|
|
52
|
+
const eventsDir = join(homedir(), ".nax", "events", project);
|
|
53
|
+
const eventsFile = join(eventsDir, "events.jsonl");
|
|
54
|
+
let dirReady = false;
|
|
55
|
+
|
|
56
|
+
const write = (line: EventLine): void => {
|
|
57
|
+
(async () => {
|
|
58
|
+
try {
|
|
59
|
+
if (!dirReady) {
|
|
60
|
+
await mkdir(eventsDir, { recursive: true });
|
|
61
|
+
dirReady = true;
|
|
62
|
+
}
|
|
63
|
+
await appendFile(eventsFile, `${JSON.stringify(line)}\n`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
logger?.warn("events-writer", "Failed to write event line (non-fatal)", {
|
|
66
|
+
event: line.event,
|
|
67
|
+
error: String(err),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const unsubs: UnsubscribeFn[] = [];
|
|
74
|
+
|
|
75
|
+
unsubs.push(
|
|
76
|
+
bus.on("run:started", (_ev) => {
|
|
77
|
+
write({ ts: new Date().toISOString(), event: "run:started", runId, feature, project });
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
unsubs.push(
|
|
82
|
+
bus.on("story:started", (ev) => {
|
|
83
|
+
write({ ts: new Date().toISOString(), event: "story:started", runId, feature, project, storyId: ev.storyId });
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
unsubs.push(
|
|
88
|
+
bus.on("story:completed", (ev) => {
|
|
89
|
+
write({ ts: new Date().toISOString(), event: "story:completed", runId, feature, project, storyId: ev.storyId });
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
unsubs.push(
|
|
94
|
+
bus.on("story:failed", (ev) => {
|
|
95
|
+
write({ ts: new Date().toISOString(), event: "story:failed", runId, feature, project, storyId: ev.storyId });
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
unsubs.push(
|
|
100
|
+
bus.on("run:completed", (_ev) => {
|
|
101
|
+
write({ ts: new Date().toISOString(), event: "on-complete", runId, feature, project });
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
unsubs.push(
|
|
106
|
+
bus.on("run:paused", (ev) => {
|
|
107
|
+
write({
|
|
108
|
+
ts: new Date().toISOString(),
|
|
109
|
+
event: "run:paused",
|
|
110
|
+
runId,
|
|
111
|
+
feature,
|
|
112
|
+
project,
|
|
113
|
+
...(ev.storyId !== undefined && { storyId: ev.storyId }),
|
|
114
|
+
});
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
for (const u of unsubs) u();
|
|
120
|
+
};
|
|
121
|
+
}
|