@nathapp/nax 0.22.4 → 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/docs/tdd/strategies.md +97 -0
- package/nax/features/central-run-registry/prd.json +105 -0
- package/nax/features/diagnose/acceptance.test.ts +3 -1
- package/package.json +3 -3
- package/src/cli/diagnose.ts +1 -1
- package/src/cli/status-features.ts +55 -7
- package/src/commands/index.ts +1 -0
- package/src/commands/logs.ts +87 -17
- package/src/commands/runs.ts +220 -0
- package/src/config/schemas.ts +3 -0
- package/src/execution/crash-recovery.ts +30 -7
- package/src/execution/lifecycle/run-setup.ts +6 -1
- package/src/execution/runner.ts +8 -0
- package/src/execution/sequential-executor.ts +4 -0
- package/src/execution/status-writer.ts +42 -0
- package/src/pipeline/subscribers/events-writer.ts +121 -0
- package/src/pipeline/subscribers/registry.ts +73 -0
- package/src/version.ts +23 -0
- package/test/e2e/plan-analyze-run.test.ts +5 -0
- package/test/integration/cli/cli-diagnose.test.ts +3 -1
- package/test/integration/cli/cli-logs.test.ts +40 -17
- package/test/integration/execution/feature-status-write.test.ts +302 -0
- package/test/integration/execution/status-file-integration.test.ts +1 -1
- package/test/integration/execution/status-writer.test.ts +112 -0
- package/test/unit/cli-status-project-level.test.ts +283 -0
- package/test/unit/commands/logs.test.ts +63 -22
- package/test/unit/commands/runs.test.ts +303 -0
- package/test/unit/config/quality-commands-schema.test.ts +72 -0
- package/test/unit/execution/sfc-004-dead-code-cleanup.test.ts +89 -0
- package/test/unit/pipeline/subscribers/events-writer.test.ts +227 -0
- package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
|
@@ -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
|
+
}
|
package/src/config/schemas.ts
CHANGED
|
@@ -117,6 +117,9 @@ const QualityConfigSchema = z.object({
|
|
|
117
117
|
typecheck: z.string().optional(),
|
|
118
118
|
lint: z.string().optional(),
|
|
119
119
|
test: z.string().optional(),
|
|
120
|
+
testScoped: z.string().optional(),
|
|
121
|
+
lintFix: z.string().optional(),
|
|
122
|
+
formatFix: z.string().optional(),
|
|
120
123
|
}),
|
|
121
124
|
forceExit: z.boolean().default(false),
|
|
122
125
|
detectOpenHandles: z.boolean().default(true),
|
|
@@ -27,6 +27,8 @@ export interface CrashRecoveryContext {
|
|
|
27
27
|
// BUG-017: Additional context for run.complete event on SIGTERM
|
|
28
28
|
runId?: string;
|
|
29
29
|
feature?: string;
|
|
30
|
+
// SFC-002: Feature directory for writing feature-level status on crash
|
|
31
|
+
featureDir?: string;
|
|
30
32
|
getStartTime?: () => number;
|
|
31
33
|
getTotalStories?: () => number;
|
|
32
34
|
getStoriesCompleted?: () => number;
|
|
@@ -115,13 +117,14 @@ async function writeRunComplete(ctx: CrashRecoveryContext, exitReason: string):
|
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
/**
|
|
118
|
-
* Update status.json to "crashed" state
|
|
120
|
+
* Update status.json to "crashed" state (both project-level and feature-level)
|
|
119
121
|
*/
|
|
120
122
|
async function updateStatusToCrashed(
|
|
121
123
|
statusWriter: StatusWriter,
|
|
122
124
|
totalCost: number,
|
|
123
125
|
iterations: number,
|
|
124
126
|
signal: string,
|
|
127
|
+
featureDir?: string,
|
|
125
128
|
): Promise<void> {
|
|
126
129
|
try {
|
|
127
130
|
statusWriter.setRunStatus("crashed");
|
|
@@ -129,6 +132,14 @@ async function updateStatusToCrashed(
|
|
|
129
132
|
crashedAt: new Date().toISOString(),
|
|
130
133
|
crashSignal: signal,
|
|
131
134
|
});
|
|
135
|
+
|
|
136
|
+
// Write feature-level status (SFC-002)
|
|
137
|
+
if (featureDir) {
|
|
138
|
+
await statusWriter.writeFeatureStatus(featureDir, totalCost, iterations, {
|
|
139
|
+
crashedAt: new Date().toISOString(),
|
|
140
|
+
crashSignal: signal,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
132
143
|
} catch (err) {
|
|
133
144
|
console.error("[crash-recovery] Failed to update status.json:", err);
|
|
134
145
|
}
|
|
@@ -166,8 +177,8 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
|
|
|
166
177
|
// Write run.complete event (BUG-017)
|
|
167
178
|
await writeRunComplete(ctx, signal.toLowerCase());
|
|
168
179
|
|
|
169
|
-
// Update status.json to crashed
|
|
170
|
-
await updateStatusToCrashed(ctx.statusWriter, ctx.getTotalCost(), ctx.getIterations(), signal);
|
|
180
|
+
// Update status.json to crashed (SFC-002: include feature-level status)
|
|
181
|
+
await updateStatusToCrashed(ctx.statusWriter, ctx.getTotalCost(), ctx.getIterations(), signal, ctx.featureDir);
|
|
171
182
|
|
|
172
183
|
// Stop heartbeat
|
|
173
184
|
stopHeartbeat();
|
|
@@ -201,8 +212,14 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
|
|
|
201
212
|
// Write fatal log with stack trace
|
|
202
213
|
await writeFatalLog(ctx.jsonlFilePath, "uncaughtException", error);
|
|
203
214
|
|
|
204
|
-
// Update status.json to crashed
|
|
205
|
-
await updateStatusToCrashed(
|
|
215
|
+
// Update status.json to crashed (SFC-002: include feature-level status)
|
|
216
|
+
await updateStatusToCrashed(
|
|
217
|
+
ctx.statusWriter,
|
|
218
|
+
ctx.getTotalCost(),
|
|
219
|
+
ctx.getIterations(),
|
|
220
|
+
"uncaughtException",
|
|
221
|
+
ctx.featureDir,
|
|
222
|
+
);
|
|
206
223
|
|
|
207
224
|
// Stop heartbeat
|
|
208
225
|
stopHeartbeat();
|
|
@@ -228,8 +245,14 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
|
|
|
228
245
|
// Write fatal log
|
|
229
246
|
await writeFatalLog(ctx.jsonlFilePath, "unhandledRejection", error);
|
|
230
247
|
|
|
231
|
-
// Update status.json to crashed
|
|
232
|
-
await updateStatusToCrashed(
|
|
248
|
+
// Update status.json to crashed (SFC-002: include feature-level status)
|
|
249
|
+
await updateStatusToCrashed(
|
|
250
|
+
ctx.statusWriter,
|
|
251
|
+
ctx.getTotalCost(),
|
|
252
|
+
ctx.getIterations(),
|
|
253
|
+
"unhandledRejection",
|
|
254
|
+
ctx.featureDir,
|
|
255
|
+
);
|
|
233
256
|
|
|
234
257
|
// Stop heartbeat
|
|
235
258
|
stopHeartbeat();
|
|
@@ -25,6 +25,7 @@ import { loadPlugins } from "../../plugins/loader";
|
|
|
25
25
|
import type { PluginRegistry } from "../../plugins/registry";
|
|
26
26
|
import type { PRD } from "../../prd";
|
|
27
27
|
import { loadPRD } from "../../prd";
|
|
28
|
+
import { NAX_BUILD_INFO, NAX_COMMIT, NAX_VERSION } from "../../version";
|
|
28
29
|
import { installCrashHandlers } from "../crash-recovery";
|
|
29
30
|
import { acquireLock, hookCtx, releaseLock } from "../helpers";
|
|
30
31
|
import { PidRegistry } from "../pid-registry";
|
|
@@ -36,6 +37,7 @@ export interface RunSetupOptions {
|
|
|
36
37
|
config: NaxConfig;
|
|
37
38
|
hooks: LoadedHooksConfig;
|
|
38
39
|
feature: string;
|
|
40
|
+
featureDir?: string;
|
|
39
41
|
dryRun: boolean;
|
|
40
42
|
statusFile: string;
|
|
41
43
|
logFilePath?: string;
|
|
@@ -117,6 +119,7 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
|
|
|
117
119
|
// BUG-017: Pass context for run.complete event on SIGTERM
|
|
118
120
|
runId: options.runId,
|
|
119
121
|
feature: options.feature,
|
|
122
|
+
featureDir: options.featureDir,
|
|
120
123
|
getStartTime: () => options.startTime,
|
|
121
124
|
getTotalStories: options.getTotalStories,
|
|
122
125
|
getStoriesCompleted: options.getStoriesCompleted,
|
|
@@ -173,12 +176,14 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
|
|
|
173
176
|
|
|
174
177
|
// Log run start
|
|
175
178
|
const routingMode = config.routing.llm?.mode ?? "hybrid";
|
|
176
|
-
logger?.info("run.start", `Starting feature: ${feature}`, {
|
|
179
|
+
logger?.info("run.start", `Starting feature: ${feature} [nax ${NAX_BUILD_INFO}]`, {
|
|
177
180
|
runId,
|
|
178
181
|
feature,
|
|
179
182
|
workdir,
|
|
180
183
|
dryRun,
|
|
181
184
|
routingMode,
|
|
185
|
+
naxVersion: NAX_VERSION,
|
|
186
|
+
naxCommit: NAX_COMMIT,
|
|
182
187
|
});
|
|
183
188
|
|
|
184
189
|
// Fire on-start hook
|
package/src/execution/runner.ts
CHANGED
|
@@ -110,6 +110,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
110
110
|
config,
|
|
111
111
|
hooks,
|
|
112
112
|
feature,
|
|
113
|
+
featureDir,
|
|
113
114
|
dryRun,
|
|
114
115
|
statusFile,
|
|
115
116
|
logFilePath,
|
|
@@ -307,6 +308,13 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
307
308
|
|
|
308
309
|
const { durationMs, runCompletedAt, finalCounts } = completionResult;
|
|
309
310
|
|
|
311
|
+
// ── Write feature-level status (SFC-002) ────────────────────────────────
|
|
312
|
+
if (featureDir) {
|
|
313
|
+
const finalStatus = isComplete(prd) ? "completed" : "failed";
|
|
314
|
+
statusWriter.setRunStatus(finalStatus);
|
|
315
|
+
await statusWriter.writeFeatureStatus(featureDir, totalCost, iterations);
|
|
316
|
+
}
|
|
317
|
+
|
|
310
318
|
// ── Output run footer in headless mode ─────────────────────────────────
|
|
311
319
|
if (headless && formatterMode !== "json") {
|
|
312
320
|
const { outputRunFooter } = await import("./lifecycle/headless-formatter");
|
|
@@ -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,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* write failure counter. Provides atomic status file writes via writeStatusFile.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { join } from "node:path";
|
|
9
10
|
import type { NaxConfig } from "../config";
|
|
10
11
|
import { getSafeLogger } from "../logger";
|
|
11
12
|
import type { PRD } from "../prd";
|
|
@@ -136,4 +137,45 @@ export class StatusWriter {
|
|
|
136
137
|
});
|
|
137
138
|
}
|
|
138
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Write the current status snapshot to feature-level status.json file.
|
|
143
|
+
*
|
|
144
|
+
* Called on run completion, failure, or crash to persist the final state
|
|
145
|
+
* to <featureDir>/status.json. Uses the same NaxStatusFile schema as
|
|
146
|
+
* the project-level status file.
|
|
147
|
+
*
|
|
148
|
+
* No-ops if _prd has not been set.
|
|
149
|
+
* On failure, logs a warning/error but does not throw (non-fatal).
|
|
150
|
+
*
|
|
151
|
+
* @param featureDir - Feature directory (e.g., nax/features/auth-system)
|
|
152
|
+
* @param totalCost - Accumulated cost at this write point
|
|
153
|
+
* @param iterations - Loop iteration count at this write point
|
|
154
|
+
* @param overrides - Optional partial snapshot overrides (spread last)
|
|
155
|
+
*/
|
|
156
|
+
async writeFeatureStatus(
|
|
157
|
+
featureDir: string,
|
|
158
|
+
totalCost: number,
|
|
159
|
+
iterations: number,
|
|
160
|
+
overrides: Partial<RunStateSnapshot> = {},
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
if (!this._prd) return;
|
|
163
|
+
const safeLogger = getSafeLogger();
|
|
164
|
+
const featureStatusPath = join(featureDir, "status.json");
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const base = this.getSnapshot(totalCost, iterations);
|
|
168
|
+
if (!base) {
|
|
169
|
+
throw new Error("Failed to get snapshot");
|
|
170
|
+
}
|
|
171
|
+
const state: RunStateSnapshot = { ...base, ...overrides };
|
|
172
|
+
await writeStatusFile(featureStatusPath, buildStatusSnapshot(state));
|
|
173
|
+
safeLogger?.debug("status-file", "Feature status written", { path: featureStatusPath });
|
|
174
|
+
} catch (err) {
|
|
175
|
+
safeLogger?.warn("status-file", "Failed to write feature status file (non-fatal)", {
|
|
176
|
+
path: featureStatusPath,
|
|
177
|
+
error: (err as Error).message,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
139
181
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Writer Subscriber
|
|
3
|
+
*
|
|
4
|
+
* Creates ~/.nax/runs/<project>-<feature>-<runId>/meta.json on run:started.
|
|
5
|
+
* Provides a persistent record of each run with paths for status and events.
|
|
6
|
+
*
|
|
7
|
+
* Design:
|
|
8
|
+
* - Best-effort: all writes wrapped in try/catch; never throws or blocks
|
|
9
|
+
* - Directory created on first write via mkdir recursive
|
|
10
|
+
* - Written once on run:started, never updated
|
|
11
|
+
* - Returns UnsubscribeFn matching wireHooks/wireEventsWriter pattern
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { basename, join } from "node:path";
|
|
17
|
+
import { getSafeLogger } from "../../logger";
|
|
18
|
+
import type { PipelineEventBus } from "../event-bus";
|
|
19
|
+
import type { UnsubscribeFn } from "./hooks";
|
|
20
|
+
|
|
21
|
+
export interface MetaJson {
|
|
22
|
+
runId: string;
|
|
23
|
+
project: string;
|
|
24
|
+
feature: string;
|
|
25
|
+
workdir: string;
|
|
26
|
+
statusPath: string;
|
|
27
|
+
eventsDir: string;
|
|
28
|
+
registeredAt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wire registry writer to the pipeline event bus.
|
|
33
|
+
*
|
|
34
|
+
* Listens to run:started and writes meta.json to
|
|
35
|
+
* ~/.nax/runs/<project>-<feature>-<runId>/meta.json.
|
|
36
|
+
*
|
|
37
|
+
* @param bus - The pipeline event bus
|
|
38
|
+
* @param feature - Feature name
|
|
39
|
+
* @param runId - Current run ID
|
|
40
|
+
* @param workdir - Working directory (project name derived via basename)
|
|
41
|
+
* @returns Unsubscribe function
|
|
42
|
+
*/
|
|
43
|
+
export function wireRegistry(bus: PipelineEventBus, feature: string, runId: string, workdir: string): UnsubscribeFn {
|
|
44
|
+
const logger = getSafeLogger();
|
|
45
|
+
const project = basename(workdir);
|
|
46
|
+
const runDir = join(homedir(), ".nax", "runs", `${project}-${feature}-${runId}`);
|
|
47
|
+
const metaFile = join(runDir, "meta.json");
|
|
48
|
+
|
|
49
|
+
const unsub = bus.on("run:started", (_ev) => {
|
|
50
|
+
(async () => {
|
|
51
|
+
try {
|
|
52
|
+
await mkdir(runDir, { recursive: true });
|
|
53
|
+
const meta: MetaJson = {
|
|
54
|
+
runId,
|
|
55
|
+
project,
|
|
56
|
+
feature,
|
|
57
|
+
workdir,
|
|
58
|
+
statusPath: join(workdir, "nax", "features", feature, "status.json"),
|
|
59
|
+
eventsDir: join(workdir, "nax", "features", feature, "runs"),
|
|
60
|
+
registeredAt: new Date().toISOString(),
|
|
61
|
+
};
|
|
62
|
+
await writeFile(metaFile, JSON.stringify(meta, null, 2));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
logger?.warn("registry-writer", "Failed to write meta.json (non-fatal)", {
|
|
65
|
+
path: metaFile,
|
|
66
|
+
error: String(err),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return unsub;
|
|
73
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version and build info for nax.
|
|
3
|
+
*
|
|
4
|
+
* GIT_COMMIT is injected at build time via --define in the bun build script.
|
|
5
|
+
* When running from source (bun run dev), it falls back to "dev".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import pkg from "../package.json";
|
|
9
|
+
|
|
10
|
+
declare const GIT_COMMIT: string;
|
|
11
|
+
|
|
12
|
+
export const NAX_VERSION: string = pkg.version;
|
|
13
|
+
|
|
14
|
+
/** Short git commit hash, injected at build time. Falls back to "dev" from source. */
|
|
15
|
+
export const NAX_COMMIT: string = (() => {
|
|
16
|
+
try {
|
|
17
|
+
return GIT_COMMIT ?? "dev";
|
|
18
|
+
} catch {
|
|
19
|
+
return "dev";
|
|
20
|
+
}
|
|
21
|
+
})();
|
|
22
|
+
|
|
23
|
+
export const NAX_BUILD_INFO = `v${NAX_VERSION} (${NAX_COMMIT})`;
|