substrate-ai 0.20.64 → 0.20.65
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/dist/adapter-registry-BbVWH3Yv.js +4 -0
- package/dist/cli/index.js +93 -24
- package/dist/{decision-router-BA__VYIp.js → decision-router-DblHY8se.js} +1 -1
- package/dist/{decisions-4F91LrVD.js → decisions-DilHo99V.js} +2 -2
- package/dist/{dist-W2emvN3F.js → dist-K_RRWnBX.js} +2 -2
- package/dist/{errors-CKFu8YI9.js → errors-pSiZbn6e.js} +2 -2
- package/dist/{experimenter-BgpUcUaW.js → experimenter-DT9v2Pto.js} +1 -1
- package/dist/health-DC3y-sR6.js +1715 -0
- package/dist/health-qhtWYh49.js +8 -0
- package/dist/index-c924O9mj.d.ts +1432 -0
- package/dist/index.d.ts +56 -735
- package/dist/index.js +2 -2
- package/dist/interactive-prompt-C7wpE4z4.js +183 -0
- package/dist/{health-DudlnqXd.js → manifest-read-DDkXC3L_.js} +120 -2012
- package/dist/modules/interactive-prompt/index.d.ts +86 -0
- package/dist/modules/interactive-prompt/index.js +6 -0
- package/dist/recovery-engine-BKGBeBnW.js +281 -0
- package/dist/{routing-0ykvBl_4.js → routing-CzF0p6lI.js} +2 -2
- package/dist/run-DX95j4_D.js +14 -0
- package/dist/{run-CCxsv-9M.js → run-DzB4rgkj.js} +224 -31
- package/dist/src/modules/decision-router/index.js +1 -1
- package/dist/src/modules/recovery-engine/index.d.ts +1101 -0
- package/dist/src/modules/recovery-engine/index.js +5 -0
- package/dist/{upgrade-OFeC_NIx.js → upgrade-DxzQ1nss.js} +3 -3
- package/dist/{upgrade-aW7GYL2F.js → upgrade-MP9XzrI6.js} +2 -2
- package/dist/version-manager-impl-GZDUBt0Q.js +4 -0
- package/dist/work-graph-repository-DZyJv5pV.js +265 -0
- package/package.json +1 -1
- package/dist/adapter-registry-k7ZX3Bz6.js +0 -4
- package/dist/health-CLNmnZiw.js +0 -6
- package/dist/run-ChxsPICN.js +0 -10
- package/dist/version-manager-impl-BCSf5E3j.js +0 -4
- /package/dist/{decisions-C0pz9Clx.js → decisions-CzSIEeGP.js} +0 -0
- /package/dist/{routing-CcBOCuC9.js → routing-DFxoKHDt.js} +0 -0
- /package/dist/{version-manager-impl-FH4TTnXm.js → version-manager-impl-qFBiO4Eh.js} +0 -0
|
@@ -1,1533 +1,62 @@
|
|
|
1
1
|
import { createLogger } from "./logger-KeHncl-f.js";
|
|
2
|
-
import {
|
|
3
|
-
import { createRequire } from "module";
|
|
2
|
+
import { LEARNING_FINDING, createDecision, getDecisionsByCategory } from "./dist-K_RRWnBX.js";
|
|
4
3
|
import * as path$1 from "path";
|
|
5
|
-
import {
|
|
4
|
+
import { join } from "path";
|
|
6
5
|
import { readFile } from "fs/promises";
|
|
7
6
|
import { EventEmitter } from "node:events";
|
|
8
7
|
import { YAMLException, load } from "js-yaml";
|
|
9
8
|
import { existsSync, promises, readFileSync, readdirSync, statSync } from "node:fs";
|
|
10
|
-
import { execSync, spawn
|
|
9
|
+
import { execSync, spawn } from "node:child_process";
|
|
11
10
|
import * as path$2 from "node:path";
|
|
12
11
|
import { basename as basename$1, dirname as dirname$1, join as join$1, resolve as resolve$1 } from "node:path";
|
|
13
12
|
import { z } from "zod";
|
|
14
|
-
import { mkdir as mkdir$1, open, readFile as readFile$1, unlink, writeFile as writeFile$1 } from "node:fs/promises";
|
|
13
|
+
import { mkdir as mkdir$1, open, readFile as readFile$1, unlink as unlink$1, writeFile as writeFile$1 } from "node:fs/promises";
|
|
15
14
|
import * as fs from "fs";
|
|
16
|
-
import {
|
|
17
|
-
import { createRequire as createRequire$1 } from "node:module";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
18
16
|
import { fileURLToPath } from "node:url";
|
|
19
17
|
import { execSync as execSync$1 } from "child_process";
|
|
20
18
|
|
|
21
|
-
//#region
|
|
22
|
-
var __create = Object.create;
|
|
23
|
-
var __defProp = Object.defineProperty;
|
|
24
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
25
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
26
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
27
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
28
|
-
var __commonJS = (cb, mod) => function() {
|
|
29
|
-
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
30
|
-
};
|
|
31
|
-
var __copyProps = (to, from, except, desc) => {
|
|
32
|
-
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
33
|
-
key = keys[i];
|
|
34
|
-
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
35
|
-
get: ((k) => from[k]).bind(null, key),
|
|
36
|
-
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
return to;
|
|
40
|
-
};
|
|
41
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
42
|
-
value: mod,
|
|
43
|
-
enumerable: true
|
|
44
|
-
}) : target, mod));
|
|
45
|
-
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
46
|
-
|
|
47
|
-
//#endregion
|
|
48
|
-
//#region src/utils/git-root.ts
|
|
49
|
-
/**
|
|
50
|
-
* Resolve the main git repository root, even from a linked worktree.
|
|
51
|
-
*
|
|
52
|
-
* In the main worktree, `--git-common-dir` returns `.git` (relative).
|
|
53
|
-
* In a linked worktree, it returns the absolute path to the main `.git` dir.
|
|
54
|
-
* Either way, `dirname()` of the resolved absolute path yields the repo root.
|
|
55
|
-
*
|
|
56
|
-
* Falls back to `cwd` if not in a git repo or git is unavailable.
|
|
57
|
-
*/
|
|
58
|
-
async function resolveMainRepoRoot(cwd = process.cwd()) {
|
|
59
|
-
return new Promise((res) => {
|
|
60
|
-
let stdout = "";
|
|
61
|
-
const proc = spawn("git", ["rev-parse", "--git-common-dir"], {
|
|
62
|
-
cwd,
|
|
63
|
-
stdio: [
|
|
64
|
-
"ignore",
|
|
65
|
-
"pipe",
|
|
66
|
-
"pipe"
|
|
67
|
-
]
|
|
68
|
-
});
|
|
69
|
-
if (proc.stdout !== null) proc.stdout.on("data", (chunk) => {
|
|
70
|
-
stdout += chunk.toString("utf-8");
|
|
71
|
-
});
|
|
72
|
-
proc.on("error", () => {
|
|
73
|
-
res(cwd);
|
|
74
|
-
});
|
|
75
|
-
proc.on("close", (code) => {
|
|
76
|
-
if (code !== 0) {
|
|
77
|
-
res(cwd);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
const commonDir = stdout.trim();
|
|
81
|
-
if (!commonDir) {
|
|
82
|
-
res(cwd);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
const absCommonDir = resolve$1(cwd, commonDir);
|
|
86
|
-
res(dirname$1(absCommonDir));
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
//#endregion
|
|
92
|
-
//#region src/persistence/adapter.ts
|
|
93
|
-
/**
|
|
94
|
-
* Create a DatabaseAdapter for the specified (or auto-detected) backend.
|
|
95
|
-
*
|
|
96
|
-
* This shim wraps the core factory and injects the concrete DoltClient
|
|
97
|
-
* constructor as the doltClientFactory parameter, so monolith callers
|
|
98
|
-
* get Dolt support transparently.
|
|
99
|
-
*/
|
|
100
|
-
function createDatabaseAdapter$1(config) {
|
|
101
|
-
return createDatabaseAdapter(config, (repoPath) => new DoltClient({ repoPath }));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
//#endregion
|
|
105
|
-
//#region src/modules/stop-after/types.ts
|
|
106
|
-
/**
|
|
107
|
-
* Stop-After Gate Module — Types
|
|
108
|
-
*
|
|
109
|
-
* Defines the PhaseName type and all parameter/result types for the stop-after gate.
|
|
110
|
-
* VALID_PHASES is the canonical source for pipeline phase names; auto.ts imports from here.
|
|
111
|
-
*/
|
|
112
|
-
/** Canonical pipeline phase names. This is the single source of truth for all phase lists. */
|
|
113
|
-
const VALID_PHASES = [
|
|
114
|
-
"research",
|
|
115
|
-
"analysis",
|
|
116
|
-
"planning",
|
|
117
|
-
"solutioning",
|
|
118
|
-
"implementation"
|
|
119
|
-
];
|
|
120
|
-
/**
|
|
121
|
-
* Alias for VALID_PHASES retained for backward compatibility with existing imports.
|
|
122
|
-
* @deprecated Use VALID_PHASES directly.
|
|
123
|
-
*/
|
|
124
|
-
const STOP_AFTER_VALID_PHASES = VALID_PHASES;
|
|
125
|
-
|
|
126
|
-
//#endregion
|
|
127
|
-
//#region src/cli/commands/pipeline-shared.ts
|
|
128
|
-
/**
|
|
129
|
-
* Parse a DB timestamp string to a Date, correctly treating it as UTC.
|
|
130
|
-
*
|
|
131
|
-
* SQLite stores timestamps as "YYYY-MM-DD HH:MM:SS" without a timezone suffix.
|
|
132
|
-
* JavaScript's Date constructor parses strings without a timezone suffix as
|
|
133
|
-
* *local time*, which causes staleness/duration to be calculated incorrectly
|
|
134
|
-
* on machines not in UTC.
|
|
135
|
-
*
|
|
136
|
-
* Fix: append 'Z' if the string has no timezone marker so it is always
|
|
137
|
-
* parsed as UTC.
|
|
138
|
-
*/
|
|
139
|
-
function parseDbTimestampAsUtc(ts) {
|
|
140
|
-
if (ts.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts);
|
|
141
|
-
return new Date(ts.replace(" ", "T") + "Z");
|
|
142
|
-
}
|
|
143
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
144
|
-
const __dirname$1 = dirname(__filename);
|
|
145
|
-
/**
|
|
146
|
-
* Find the package root by walking up until we find package.json.
|
|
147
|
-
* Works regardless of build output structure (tsdown bundles into
|
|
148
|
-
* dist/cli/index.js, not dist/cli/commands/auto.js).
|
|
149
|
-
*/
|
|
150
|
-
function findPackageRoot(startDir) {
|
|
151
|
-
let dir = startDir;
|
|
152
|
-
while (dir !== dirname(dir)) {
|
|
153
|
-
if (existsSync$1(join(dir, "package.json"))) return dir;
|
|
154
|
-
dir = dirname(dir);
|
|
155
|
-
}
|
|
156
|
-
return startDir;
|
|
157
|
-
}
|
|
158
|
-
const PACKAGE_ROOT = join(__dirname$1, "..", "..", "..");
|
|
159
|
-
/**
|
|
160
|
-
* Resolve the absolute path to the bmad-method package's src/ directory.
|
|
161
|
-
* Uses createRequire so it works in ESM without import.meta.resolve polyfills.
|
|
162
|
-
* Returns null if bmad-method is not installed.
|
|
163
|
-
*/
|
|
164
|
-
function resolveBmadMethodSrcPath(fromDir = __dirname$1) {
|
|
165
|
-
try {
|
|
166
|
-
const require$1 = createRequire$1(join(fromDir, "synthetic.js"));
|
|
167
|
-
const pkgJsonPath = require$1.resolve("bmad-method/package.json");
|
|
168
|
-
return join(dirname(pkgJsonPath), "src");
|
|
169
|
-
} catch {
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* Read the version field from bmad-method's package.json.
|
|
175
|
-
* Returns 'unknown' if not resolvable.
|
|
176
|
-
*/
|
|
177
|
-
function resolveBmadMethodVersion(fromDir = __dirname$1) {
|
|
178
|
-
try {
|
|
179
|
-
const require$1 = createRequire$1(join(fromDir, "synthetic.js"));
|
|
180
|
-
const pkgJsonPath = require$1.resolve("bmad-method/package.json");
|
|
181
|
-
const pkg = require$1(pkgJsonPath);
|
|
182
|
-
return pkg.version ?? "unknown";
|
|
183
|
-
} catch {
|
|
184
|
-
return "unknown";
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
/** BMAD baseline token total for full pipeline comparison (analysis+planning+solutioning+implementation) */
|
|
188
|
-
const BMAD_BASELINE_TOKENS_FULL = 56800;
|
|
189
|
-
/** BMAD baseline token total for create+dev+review comparison */
|
|
190
|
-
const BMAD_BASELINE_TOKENS = 23800;
|
|
191
|
-
/** Story key pattern: e.g. "10-1", "1-1a", "NEW-26", "E6" */
|
|
192
|
-
const STORY_KEY_PATTERN$1 = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)?$/;
|
|
193
|
-
/**
|
|
194
|
-
* Top-level keys in .claude/settings.json that substrate owns.
|
|
195
|
-
* On init, these are set/updated unconditionally.
|
|
196
|
-
* User-defined keys outside this set are never touched.
|
|
197
|
-
*/
|
|
198
|
-
const SUBSTRATE_OWNED_SETTINGS_KEYS = ["statusLine"];
|
|
199
|
-
function getSubstrateDefaultSettings() {
|
|
200
|
-
return { statusLine: {
|
|
201
|
-
type: "command",
|
|
202
|
-
command: "bash \"$CLAUDE_PROJECT_DIR\"/.claude/statusline.sh",
|
|
203
|
-
padding: 0
|
|
204
|
-
} };
|
|
205
|
-
}
|
|
206
|
-
/**
|
|
207
|
-
* Format output according to the requested format.
|
|
208
|
-
*/
|
|
209
|
-
function formatOutput(data, format, success = true, errorMessage) {
|
|
210
|
-
if (format === "json") {
|
|
211
|
-
if (!success) return JSON.stringify({
|
|
212
|
-
success: false,
|
|
213
|
-
error: errorMessage ?? "Unknown error"
|
|
214
|
-
});
|
|
215
|
-
return JSON.stringify({
|
|
216
|
-
success: true,
|
|
217
|
-
data
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
if (typeof data === "string") return data;
|
|
221
|
-
return JSON.stringify(data, null, 2);
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* Build a human-readable token telemetry display from summary rows.
|
|
225
|
-
*/
|
|
226
|
-
function formatTokenTelemetry(summary, baselineTokens = BMAD_BASELINE_TOKENS) {
|
|
227
|
-
if (summary.length === 0) return "No token usage recorded.";
|
|
228
|
-
let totalInput = 0;
|
|
229
|
-
let totalOutput = 0;
|
|
230
|
-
let totalCost = 0;
|
|
231
|
-
const lines = ["Pipeline Token Usage:"];
|
|
232
|
-
for (const row of summary) {
|
|
233
|
-
totalInput += row.total_input_tokens;
|
|
234
|
-
totalOutput += row.total_output_tokens;
|
|
235
|
-
totalCost += row.total_cost_usd;
|
|
236
|
-
const cost = `$${row.total_cost_usd.toFixed(4)}`;
|
|
237
|
-
lines.push(` ${row.phase} (${row.agent}): ${row.total_input_tokens.toLocaleString()} input / ${row.total_output_tokens.toLocaleString()} output (${cost})`);
|
|
238
|
-
}
|
|
239
|
-
lines.push(" " + "─".repeat(55));
|
|
240
|
-
const costDisplay = `$${totalCost.toFixed(4)}`;
|
|
241
|
-
lines.push(` Total: ${totalInput.toLocaleString()} input / ${totalOutput.toLocaleString()} output (${costDisplay})`);
|
|
242
|
-
const totalTokens = totalInput + totalOutput;
|
|
243
|
-
const savingsPct = baselineTokens > 0 ? Math.round((baselineTokens - totalTokens) / baselineTokens * 100) : 0;
|
|
244
|
-
const savingsLabel = savingsPct >= 0 ? `Savings: ${savingsPct}%` : `Overhead: +${Math.abs(savingsPct)}%`;
|
|
245
|
-
lines.push(` BMAD Baseline: ${baselineTokens.toLocaleString()} tokens → ${savingsLabel}`);
|
|
246
|
-
return lines.join("\n");
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
|
-
* Validate a story key has the expected format: <epic>-<story> (e.g., "10-1").
|
|
250
|
-
*/
|
|
251
|
-
function validateStoryKey(key) {
|
|
252
|
-
return STORY_KEY_PATTERN$1.test(key);
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Build the AC5 JSON status schema for a pipeline run.
|
|
256
|
-
*/
|
|
257
|
-
function buildPipelineStatusOutput(run, tokenSummary, decisionsCount, storiesCount) {
|
|
258
|
-
const phases = {};
|
|
259
|
-
const phaseTokenMap = {};
|
|
260
|
-
for (const row of tokenSummary) {
|
|
261
|
-
if (!phaseTokenMap[row.phase]) phaseTokenMap[row.phase] = {
|
|
262
|
-
input: 0,
|
|
263
|
-
output: 0
|
|
264
|
-
};
|
|
265
|
-
phaseTokenMap[row.phase].input += row.total_input_tokens;
|
|
266
|
-
phaseTokenMap[row.phase].output += row.total_output_tokens;
|
|
267
|
-
}
|
|
268
|
-
let phaseHistory = [];
|
|
269
|
-
try {
|
|
270
|
-
if (run.config_json) {
|
|
271
|
-
const config = JSON.parse(run.config_json);
|
|
272
|
-
phaseHistory = config.phaseHistory ?? [];
|
|
273
|
-
}
|
|
274
|
-
} catch {}
|
|
275
|
-
const currentPhase = run.current_phase ?? null;
|
|
276
|
-
for (const phaseName of VALID_PHASES) {
|
|
277
|
-
const historyEntry = phaseHistory.find((h) => h.phase === phaseName);
|
|
278
|
-
const tokenUsage = phaseTokenMap[phaseName] ?? {
|
|
279
|
-
input: 0,
|
|
280
|
-
output: 0
|
|
281
|
-
};
|
|
282
|
-
if (historyEntry?.completedAt) {
|
|
283
|
-
phases[phaseName] = {
|
|
284
|
-
status: "complete",
|
|
285
|
-
completed_at: historyEntry.completedAt,
|
|
286
|
-
token_usage: tokenUsage
|
|
287
|
-
};
|
|
288
|
-
if (historyEntry.startedAt) phases[phaseName].started_at = historyEntry.startedAt;
|
|
289
|
-
} else if (phaseName === currentPhase || historyEntry?.startedAt) phases[phaseName] = {
|
|
290
|
-
status: "running",
|
|
291
|
-
started_at: historyEntry?.startedAt,
|
|
292
|
-
token_usage: tokenUsage
|
|
293
|
-
};
|
|
294
|
-
else phases[phaseName] = { status: "pending" };
|
|
295
|
-
}
|
|
296
|
-
let totalInput = 0;
|
|
297
|
-
let totalOutput = 0;
|
|
298
|
-
let totalCost = 0;
|
|
299
|
-
for (const row of tokenSummary) {
|
|
300
|
-
totalInput += row.total_input_tokens;
|
|
301
|
-
totalOutput += row.total_output_tokens;
|
|
302
|
-
totalCost += row.total_cost_usd;
|
|
303
|
-
}
|
|
304
|
-
let activeDispatches = 0;
|
|
305
|
-
let storiesSummary;
|
|
306
|
-
try {
|
|
307
|
-
if (run.token_usage_json) {
|
|
308
|
-
const state = JSON.parse(run.token_usage_json);
|
|
309
|
-
if (state.stories && Object.keys(state.stories).length > 0) {
|
|
310
|
-
const now = Date.now();
|
|
311
|
-
let completed = 0;
|
|
312
|
-
let inProgress = 0;
|
|
313
|
-
let escalated = 0;
|
|
314
|
-
let pending = 0;
|
|
315
|
-
const details = {};
|
|
316
|
-
for (const [key, s] of Object.entries(state.stories)) {
|
|
317
|
-
const phase = s.phase ?? "PENDING";
|
|
318
|
-
if (phase !== "PENDING" && phase !== "COMPLETE" && phase !== "ESCALATED") activeDispatches++;
|
|
319
|
-
if (phase === "COMPLETE") completed++;
|
|
320
|
-
else if (phase === "ESCALATED") escalated++;
|
|
321
|
-
else if (phase === "PENDING") pending++;
|
|
322
|
-
else inProgress++;
|
|
323
|
-
const elapsed = s.startedAt != null ? Math.max(0, Math.round((now - new Date(s.startedAt).getTime()) / 1e3)) : 0;
|
|
324
|
-
details[key] = {
|
|
325
|
-
phase,
|
|
326
|
-
review_cycles: s.reviewCycles ?? 0,
|
|
327
|
-
elapsed_seconds: elapsed
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
storiesSummary = {
|
|
331
|
-
completed,
|
|
332
|
-
in_progress: inProgress,
|
|
333
|
-
escalated,
|
|
334
|
-
pending,
|
|
335
|
-
details
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
} catch {}
|
|
340
|
-
const derivedStoriesCount = storiesSummary !== void 0 ? storiesSummary.completed + storiesSummary.in_progress + storiesSummary.escalated + storiesSummary.pending : storiesCount;
|
|
341
|
-
const derivedStoriesCompleted = storiesSummary !== void 0 ? storiesSummary.completed : 0;
|
|
342
|
-
return {
|
|
343
|
-
run_id: run.id,
|
|
344
|
-
current_phase: currentPhase,
|
|
345
|
-
phases,
|
|
346
|
-
total_tokens: {
|
|
347
|
-
input: totalInput,
|
|
348
|
-
output: totalOutput,
|
|
349
|
-
cost_usd: totalCost
|
|
350
|
-
},
|
|
351
|
-
decisions_count: decisionsCount,
|
|
352
|
-
stories_count: derivedStoriesCount,
|
|
353
|
-
stories_completed: derivedStoriesCompleted,
|
|
354
|
-
last_activity: run.updated_at ?? "",
|
|
355
|
-
staleness_seconds: Math.round((Date.now() - parseDbTimestampAsUtc(run.updated_at ?? "").getTime()) / 1e3),
|
|
356
|
-
last_event_ts: run.updated_at ?? "",
|
|
357
|
-
active_dispatches: activeDispatches,
|
|
358
|
-
...storiesSummary !== void 0 ? { stories: storiesSummary } : {}
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
/**
|
|
362
|
-
* Format a pipeline status summary in human-readable format.
|
|
363
|
-
*/
|
|
364
|
-
function formatPipelineStatusHuman(status) {
|
|
365
|
-
const lines = [];
|
|
366
|
-
lines.push(`Pipeline Run: ${status.run_id}`);
|
|
367
|
-
lines.push(` Current Phase: ${status.current_phase ?? "N/A"}`);
|
|
368
|
-
lines.push("");
|
|
369
|
-
lines.push(" Phase Status:");
|
|
370
|
-
const statusIcons = {
|
|
371
|
-
complete: "[DONE]",
|
|
372
|
-
running: "[RUN] ",
|
|
373
|
-
pending: "[ ]"
|
|
374
|
-
};
|
|
375
|
-
for (const [phaseName, phaseInfo] of Object.entries(status.phases)) {
|
|
376
|
-
const icon = statusIcons[phaseInfo.status] ?? "[?]";
|
|
377
|
-
let line = ` ${icon} ${phaseName}`;
|
|
378
|
-
if (phaseInfo.status === "complete" && phaseInfo.completed_at) line += ` (completed: ${phaseInfo.completed_at})`;
|
|
379
|
-
if (phaseInfo.token_usage && (phaseInfo.token_usage.input > 0 || phaseInfo.token_usage.output > 0)) line += ` — tokens: ${phaseInfo.token_usage.input.toLocaleString()} in / ${phaseInfo.token_usage.output.toLocaleString()} out`;
|
|
380
|
-
lines.push(line);
|
|
381
|
-
}
|
|
382
|
-
lines.push("");
|
|
383
|
-
lines.push(` Total Tokens: ${(status.total_tokens.input + status.total_tokens.output).toLocaleString()} (in: ${status.total_tokens.input.toLocaleString()}, out: ${status.total_tokens.output.toLocaleString()})`);
|
|
384
|
-
lines.push(` Total Cost: $${status.total_tokens.cost_usd.toFixed(4)}`);
|
|
385
|
-
lines.push(` Decisions: ${status.decisions_count}`);
|
|
386
|
-
lines.push(` Stories: ${status.stories_count}`);
|
|
387
|
-
if (status.stories !== void 0 && Object.keys(status.stories.details).length > 0) {
|
|
388
|
-
lines.push("");
|
|
389
|
-
lines.push(" Sprint Progress:");
|
|
390
|
-
lines.push(" " + "─".repeat(68));
|
|
391
|
-
lines.push(` ${"STORY".padEnd(10)} ${"PHASE".padEnd(24)} ${"CYCLES".padEnd(8)} ELAPSED`);
|
|
392
|
-
lines.push(" " + "─".repeat(68));
|
|
393
|
-
for (const [key, detail] of Object.entries(status.stories.details)) {
|
|
394
|
-
const elapsed = detail.elapsed_seconds > 0 ? `${detail.elapsed_seconds}s` : "-";
|
|
395
|
-
lines.push(` ${key.padEnd(10)} ${detail.phase.padEnd(24)} ${String(detail.review_cycles).padEnd(8)} ${elapsed}`);
|
|
396
|
-
}
|
|
397
|
-
lines.push(" " + "─".repeat(68));
|
|
398
|
-
lines.push(` Completed: ${status.stories.completed} In Progress: ${status.stories.in_progress} Escalated: ${status.stories.escalated} Pending: ${status.stories.pending}`);
|
|
399
|
-
}
|
|
400
|
-
return lines.join("\n");
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* Format a complete pipeline run summary.
|
|
404
|
-
*/
|
|
405
|
-
function formatPipelineSummary(run, tokenSummary, decisionsCount, storiesCount, durationMs, format) {
|
|
406
|
-
let totalInput = 0;
|
|
407
|
-
let totalOutput = 0;
|
|
408
|
-
let totalCost = 0;
|
|
409
|
-
for (const row of tokenSummary) {
|
|
410
|
-
totalInput += row.total_input_tokens;
|
|
411
|
-
totalOutput += row.total_output_tokens;
|
|
412
|
-
totalCost += row.total_cost_usd;
|
|
413
|
-
}
|
|
414
|
-
const totalTokens = totalInput + totalOutput;
|
|
415
|
-
const savingsPct = BMAD_BASELINE_TOKENS_FULL > 0 ? Math.round((BMAD_BASELINE_TOKENS_FULL - totalTokens) / BMAD_BASELINE_TOKENS_FULL * 100) : 0;
|
|
416
|
-
const durationSec = Math.round(durationMs / 1e3);
|
|
417
|
-
if (format === "json") return JSON.stringify({
|
|
418
|
-
run_id: run.id,
|
|
419
|
-
status: run.status,
|
|
420
|
-
duration_ms: durationMs,
|
|
421
|
-
phases_completed: VALID_PHASES.length,
|
|
422
|
-
decisions_count: decisionsCount,
|
|
423
|
-
stories_count: storiesCount,
|
|
424
|
-
token_usage: {
|
|
425
|
-
input: totalInput,
|
|
426
|
-
output: totalOutput,
|
|
427
|
-
total: totalTokens,
|
|
428
|
-
cost_usd: totalCost,
|
|
429
|
-
bmad_baseline: BMAD_BASELINE_TOKENS_FULL,
|
|
430
|
-
savings_pct: savingsPct
|
|
431
|
-
}
|
|
432
|
-
});
|
|
433
|
-
const lines = [
|
|
434
|
-
"┌─────────────────────────────────────────────────────┐",
|
|
435
|
-
"│ Pipeline Run Summary │",
|
|
436
|
-
"└─────────────────────────────────────────────────────┘",
|
|
437
|
-
` Run ID: ${run.id}`,
|
|
438
|
-
` Status: ${run.status}`,
|
|
439
|
-
` Duration: ${durationSec}s`,
|
|
440
|
-
` Phases Complete: ${VALID_PHASES.length}`,
|
|
441
|
-
` Decisions: ${decisionsCount}`,
|
|
442
|
-
` Stories: ${storiesCount}`,
|
|
443
|
-
"",
|
|
444
|
-
` Token Usage: ${totalTokens.toLocaleString()} total`,
|
|
445
|
-
` Input: ${totalInput.toLocaleString()}`,
|
|
446
|
-
` Output: ${totalOutput.toLocaleString()}`,
|
|
447
|
-
` Cost: $${totalCost.toFixed(4)}`,
|
|
448
|
-
"",
|
|
449
|
-
` BMAD Baseline: ${BMAD_BASELINE_TOKENS_FULL.toLocaleString()} tokens`,
|
|
450
|
-
` Token Savings: ${savingsPct >= 0 ? savingsPct + "%" : "N/A (overhead)"}`
|
|
451
|
-
];
|
|
452
|
-
return lines.join("\n");
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
//#endregion
|
|
456
|
-
//#region src/modules/work-graph/cycle-detector.ts
|
|
457
|
-
/**
|
|
458
|
-
* detectCycles — DFS-based cycle detection for story dependency graphs.
|
|
459
|
-
*
|
|
460
|
-
* Story 31-7: Cycle Detection in Work Graph
|
|
461
|
-
*
|
|
462
|
-
* Pure function; no database or I/O dependencies.
|
|
463
|
-
*/
|
|
464
|
-
/**
|
|
465
|
-
* Detect cycles in a directed dependency graph represented as an edge list.
|
|
466
|
-
*
|
|
467
|
-
* Each edge `{ story_key, depends_on }` means story_key depends on depends_on
|
|
468
|
-
* (i.e. story_key → depends_on is the directed edge we traverse).
|
|
469
|
-
*
|
|
470
|
-
* Uses iterative DFS with an explicit stack to avoid call-stack overflows on
|
|
471
|
-
* large graphs, but also supports a nested recursive helper for cycle path
|
|
472
|
-
* reconstruction.
|
|
473
|
-
*
|
|
474
|
-
* @param edges - List of dependency edges to check.
|
|
475
|
-
* @returns `null` if the graph is acyclic (safe to persist), or a `string[]`
|
|
476
|
-
* containing the cycle path with the first and last element being the same
|
|
477
|
-
* story key (e.g. `['A', 'B', 'A']`).
|
|
478
|
-
*/
|
|
479
|
-
function detectCycles(edges) {
|
|
480
|
-
const adj = new Map();
|
|
481
|
-
for (const { story_key, depends_on } of edges) {
|
|
482
|
-
if (!adj.has(story_key)) adj.set(story_key, []);
|
|
483
|
-
adj.get(story_key).push(depends_on);
|
|
484
|
-
}
|
|
485
|
-
const visited = new Set();
|
|
486
|
-
const visiting = new Set();
|
|
487
|
-
const path$3 = [];
|
|
488
|
-
function dfs(node) {
|
|
489
|
-
if (visiting.has(node)) {
|
|
490
|
-
const cycleStart = path$3.indexOf(node);
|
|
491
|
-
return [...path$3.slice(cycleStart), node];
|
|
492
|
-
}
|
|
493
|
-
if (visited.has(node)) return null;
|
|
494
|
-
visiting.add(node);
|
|
495
|
-
path$3.push(node);
|
|
496
|
-
for (const neighbor of adj.get(node) ?? []) {
|
|
497
|
-
const cycle = dfs(neighbor);
|
|
498
|
-
if (cycle !== null) return cycle;
|
|
499
|
-
}
|
|
500
|
-
path$3.pop();
|
|
501
|
-
visiting.delete(node);
|
|
502
|
-
visited.add(node);
|
|
503
|
-
return null;
|
|
504
|
-
}
|
|
505
|
-
const allNodes = new Set([...edges.map((e) => e.story_key), ...edges.map((e) => e.depends_on)]);
|
|
506
|
-
for (const node of allNodes) if (!visited.has(node)) {
|
|
507
|
-
const cycle = dfs(node);
|
|
508
|
-
if (cycle !== null) return cycle;
|
|
509
|
-
}
|
|
510
|
-
return null;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
//#endregion
|
|
514
|
-
//#region src/modules/state/work-graph-repository.ts
|
|
515
|
-
var WorkGraphRepository = class {
|
|
516
|
-
constructor(db) {
|
|
517
|
-
this.db = db;
|
|
518
|
-
}
|
|
519
|
-
/**
|
|
520
|
-
* Insert or replace a work-graph story node.
|
|
521
|
-
* Uses DELETE + INSERT so it works on InMemoryDatabaseAdapter (which does
|
|
522
|
-
* not support ON DUPLICATE KEY UPDATE).
|
|
523
|
-
*/
|
|
524
|
-
async upsertStory(story) {
|
|
525
|
-
await this.db.query(`DELETE FROM wg_stories WHERE story_key = ?`, [story.story_key]);
|
|
526
|
-
await this.db.query(`INSERT INTO wg_stories (story_key, epic, title, status, spec_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
527
|
-
story.story_key,
|
|
528
|
-
story.epic,
|
|
529
|
-
story.title ?? null,
|
|
530
|
-
story.status,
|
|
531
|
-
story.spec_path ?? null,
|
|
532
|
-
story.created_at ?? null,
|
|
533
|
-
story.updated_at ?? null,
|
|
534
|
-
story.completed_at ?? null
|
|
535
|
-
]);
|
|
536
|
-
}
|
|
537
|
-
/**
|
|
538
|
-
* Insert a dependency edge. Idempotent — if a row with the same
|
|
539
|
-
* (story_key, depends_on) already exists it is silently skipped.
|
|
540
|
-
*/
|
|
541
|
-
async addDependency(dep) {
|
|
542
|
-
const existing = await this.db.query(`SELECT story_key FROM story_dependencies WHERE story_key = ? AND depends_on = ?`, [dep.story_key, dep.depends_on]);
|
|
543
|
-
if (existing.length > 0) return;
|
|
544
|
-
await this.db.query(`INSERT INTO story_dependencies (story_key, depends_on, dependency_type, source, created_at) VALUES (?, ?, ?, ?, ?)`, [
|
|
545
|
-
dep.story_key,
|
|
546
|
-
dep.depends_on,
|
|
547
|
-
dep.dependency_type,
|
|
548
|
-
dep.source,
|
|
549
|
-
dep.created_at ?? null
|
|
550
|
-
]);
|
|
551
|
-
}
|
|
552
|
-
/**
|
|
553
|
-
* Persist contract-based dependency edges to `story_dependencies` as
|
|
554
|
-
* best-effort, idempotent writes.
|
|
555
|
-
*
|
|
556
|
-
* - edges where `reason` does NOT start with `'dual export:'` are persisted
|
|
557
|
-
* as `dependency_type = 'blocks'` (hard prerequisites).
|
|
558
|
-
* - edges where `reason` starts with `'dual export:'` are persisted as
|
|
559
|
-
* `dependency_type = 'informs'` (serialization hints, not hard gates).
|
|
560
|
-
*
|
|
561
|
-
* Idempotency is delegated to `addDependency()`, which skips the INSERT if
|
|
562
|
-
* a row with the same `(story_key, depends_on)` already exists.
|
|
563
|
-
*
|
|
564
|
-
* @param edges - Readonly list of contract dependency edges to persist.
|
|
565
|
-
*/
|
|
566
|
-
async addContractDependencies(edges) {
|
|
567
|
-
if (edges.length === 0) return;
|
|
568
|
-
for (const edge of edges) {
|
|
569
|
-
const dependency_type = edge.reason?.startsWith("dual export:") ? "informs" : "blocks";
|
|
570
|
-
await this.addDependency({
|
|
571
|
-
story_key: edge.to,
|
|
572
|
-
depends_on: edge.from,
|
|
573
|
-
dependency_type,
|
|
574
|
-
source: "contract",
|
|
575
|
-
created_at: new Date().toISOString()
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
/**
|
|
580
|
-
* Return all work-graph stories, optionally filtered by epic and/or status.
|
|
581
|
-
*/
|
|
582
|
-
async listStories(filter) {
|
|
583
|
-
if (!filter || !filter.epic && !filter.status) return this.db.query(`SELECT * FROM wg_stories`);
|
|
584
|
-
const conditions = [];
|
|
585
|
-
const params = [];
|
|
586
|
-
if (filter.epic) {
|
|
587
|
-
conditions.push(`epic = ?`);
|
|
588
|
-
params.push(filter.epic);
|
|
589
|
-
}
|
|
590
|
-
if (filter.status) {
|
|
591
|
-
conditions.push(`status = ?`);
|
|
592
|
-
params.push(filter.status);
|
|
593
|
-
}
|
|
594
|
-
const where = conditions.join(" AND ");
|
|
595
|
-
return this.db.query(`SELECT * FROM wg_stories WHERE ${where}`, params);
|
|
596
|
-
}
|
|
597
|
-
/**
|
|
598
|
-
* Update the `status` (and optionally `completed_at`) of an existing
|
|
599
|
-
* work-graph story.
|
|
600
|
-
*
|
|
601
|
-
* This is a read-modify-write operation: SELECT existing row → build
|
|
602
|
-
* updated WgStory → upsertStory(). If no row exists for `storyKey` the
|
|
603
|
-
* call is a no-op (AC4).
|
|
604
|
-
*
|
|
605
|
-
* @param storyKey - Story identifier, e.g. "31-4"
|
|
606
|
-
* @param status - Target WgStoryStatus value
|
|
607
|
-
* @param opts - Optional `completedAt` ISO string for terminal phases
|
|
608
|
-
*/
|
|
609
|
-
async updateStoryStatus(storyKey, status, opts) {
|
|
610
|
-
const rows = await this.db.query(`SELECT * FROM wg_stories WHERE story_key = ?`, [storyKey]);
|
|
611
|
-
if (rows.length === 0) return;
|
|
612
|
-
const existing = rows[0];
|
|
613
|
-
const now = new Date().toISOString();
|
|
614
|
-
const isTerminal = status === "complete" || status === "escalated";
|
|
615
|
-
const updated = {
|
|
616
|
-
...existing,
|
|
617
|
-
status,
|
|
618
|
-
updated_at: now,
|
|
619
|
-
completed_at: isTerminal ? opts?.completedAt ?? now : existing.completed_at
|
|
620
|
-
};
|
|
621
|
-
await this.upsertStory(updated);
|
|
622
|
-
}
|
|
623
|
-
/**
|
|
624
|
-
* Return stories that are eligible for dispatch.
|
|
625
|
-
*
|
|
626
|
-
* A story is ready when:
|
|
627
|
-
* 1. Its status is 'planned' or 'ready', AND
|
|
628
|
-
* 2. It has no 'blocks' dependency whose blocking story is not 'complete'.
|
|
629
|
-
*
|
|
630
|
-
* Soft ('informs') dependencies never block dispatch.
|
|
631
|
-
*
|
|
632
|
-
* This is implemented programmatically rather than via the `ready_stories`
|
|
633
|
-
* VIEW so that the InMemoryDatabaseAdapter can handle it without VIEW support.
|
|
634
|
-
*/
|
|
635
|
-
async getReadyStories() {
|
|
636
|
-
const allStories = await this.db.query(`SELECT * FROM wg_stories`);
|
|
637
|
-
const candidates = allStories.filter((s) => s.status === "planned" || s.status === "ready");
|
|
638
|
-
if (candidates.length === 0) return [];
|
|
639
|
-
const deps = await this.db.query(`SELECT story_key, depends_on FROM story_dependencies WHERE dependency_type = 'blocks'`);
|
|
640
|
-
if (deps.length === 0) return candidates;
|
|
641
|
-
const blockerStatus = new Map(allStories.map((s) => [s.story_key, s.status]));
|
|
642
|
-
const depsMap = new Map();
|
|
643
|
-
for (const d of deps) {
|
|
644
|
-
if (!depsMap.has(d.story_key)) depsMap.set(d.story_key, []);
|
|
645
|
-
depsMap.get(d.story_key).push(d.depends_on);
|
|
646
|
-
}
|
|
647
|
-
return candidates.filter((s) => {
|
|
648
|
-
const blocking = depsMap.get(s.story_key) ?? [];
|
|
649
|
-
return blocking.every((dep) => blockerStatus.get(dep) === "complete");
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
/**
|
|
653
|
-
* Return stories that are planned/ready but cannot be dispatched because
|
|
654
|
-
* at least one hard-blocking ('blocks') dependency is not yet complete.
|
|
655
|
-
*
|
|
656
|
-
* For each blocked story, the returned object includes the full WgStory
|
|
657
|
-
* record plus the list of unsatisfied blockers (key, title, status).
|
|
658
|
-
*
|
|
659
|
-
* Soft ('informs') dependencies are ignored here, matching getReadyStories().
|
|
660
|
-
*/
|
|
661
|
-
/**
|
|
662
|
-
* Query the database for all 'blocks' dependency rows and run DFS cycle
|
|
663
|
-
* detection over them.
|
|
664
|
-
*
|
|
665
|
-
* Returns an empty array if no cycle is found (consistent with other
|
|
666
|
-
* repository methods that return empty arrays rather than null).
|
|
667
|
-
*
|
|
668
|
-
* Only 'blocks' deps are checked — soft 'informs' deps cannot cause
|
|
669
|
-
* dispatch deadlocks (AC5).
|
|
670
|
-
*/
|
|
671
|
-
async detectCycles() {
|
|
672
|
-
const rows = await this.db.query(`SELECT story_key, depends_on FROM story_dependencies WHERE dependency_type = 'blocks'`);
|
|
673
|
-
const cycle = detectCycles(rows);
|
|
674
|
-
return cycle ?? [];
|
|
675
|
-
}
|
|
676
|
-
async getBlockedStories() {
|
|
677
|
-
const allStories = await this.db.query(`SELECT * FROM wg_stories`);
|
|
678
|
-
const candidates = allStories.filter((s) => s.status === "planned" || s.status === "ready");
|
|
679
|
-
if (candidates.length === 0) return [];
|
|
680
|
-
const deps = await this.db.query(`SELECT story_key, depends_on FROM story_dependencies WHERE dependency_type = 'blocks'`);
|
|
681
|
-
if (deps.length === 0) return [];
|
|
682
|
-
const statusMap = new Map(allStories.map((s) => [s.story_key, s]));
|
|
683
|
-
const depsMap = new Map();
|
|
684
|
-
for (const d of deps) {
|
|
685
|
-
if (!depsMap.has(d.story_key)) depsMap.set(d.story_key, []);
|
|
686
|
-
depsMap.get(d.story_key).push(d.depends_on);
|
|
687
|
-
}
|
|
688
|
-
const result = [];
|
|
689
|
-
for (const story of candidates) {
|
|
690
|
-
const blockerKeys = depsMap.get(story.story_key) ?? [];
|
|
691
|
-
const unsatisfied = blockerKeys.filter((key) => statusMap.get(key)?.status !== "complete").map((key) => {
|
|
692
|
-
const s = statusMap.get(key);
|
|
693
|
-
return {
|
|
694
|
-
key,
|
|
695
|
-
title: s?.title ?? key,
|
|
696
|
-
status: s?.status ?? "unknown"
|
|
697
|
-
};
|
|
698
|
-
});
|
|
699
|
-
if (unsatisfied.length > 0) result.push({
|
|
700
|
-
story,
|
|
701
|
-
blockers: unsatisfied
|
|
702
|
-
});
|
|
703
|
-
}
|
|
704
|
-
return result;
|
|
705
|
-
}
|
|
706
|
-
};
|
|
707
|
-
|
|
708
|
-
//#endregion
|
|
709
|
-
//#region src/modules/state/file-store.ts
|
|
710
|
-
/**
|
|
711
|
-
* In-memory / file-backed StateStore implementation.
|
|
712
|
-
*
|
|
713
|
-
* Suitable for CI environments and testing where orchestrator state is
|
|
714
|
-
* ephemeral. Use DoltStateStore for branch-per-story isolation and versioned
|
|
715
|
-
* history in production.
|
|
716
|
-
*/
|
|
717
|
-
var FileStateStore = class {
|
|
718
|
-
_basePath;
|
|
719
|
-
_stories = new Map();
|
|
720
|
-
_metrics = [];
|
|
721
|
-
_contracts = new Map();
|
|
722
|
-
_contractVerifications = new Map();
|
|
723
|
-
/** Key-value metrics store: outer key = runId, inner key = metric key */
|
|
724
|
-
_kvMetrics = new Map();
|
|
725
|
-
constructor(options = {}) {
|
|
726
|
-
this._basePath = options.basePath;
|
|
727
|
-
}
|
|
728
|
-
async initialize() {}
|
|
729
|
-
async close() {}
|
|
730
|
-
async getStoryState(storyKey) {
|
|
731
|
-
return this._stories.get(storyKey);
|
|
732
|
-
}
|
|
733
|
-
async setStoryState(storyKey, state) {
|
|
734
|
-
this._stories.set(storyKey, {
|
|
735
|
-
...state,
|
|
736
|
-
storyKey
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
async queryStories(filter) {
|
|
740
|
-
const all = Array.from(this._stories.values());
|
|
741
|
-
return all.filter((record) => {
|
|
742
|
-
if (filter.phase !== void 0) {
|
|
743
|
-
const phases = Array.isArray(filter.phase) ? filter.phase : [filter.phase];
|
|
744
|
-
if (!phases.includes(record.phase)) return false;
|
|
745
|
-
}
|
|
746
|
-
if (filter.sprint !== void 0 && record.sprint !== filter.sprint) return false;
|
|
747
|
-
if (filter.storyKey !== void 0 && record.storyKey !== filter.storyKey) return false;
|
|
748
|
-
return true;
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
async recordMetric(metric) {
|
|
752
|
-
const record = {
|
|
753
|
-
...metric,
|
|
754
|
-
recordedAt: metric.recordedAt ?? new Date().toISOString()
|
|
755
|
-
};
|
|
756
|
-
this._metrics.push(record);
|
|
757
|
-
}
|
|
758
|
-
async queryMetrics(filter) {
|
|
759
|
-
const storyKey = filter.storyKey ?? filter.story_key;
|
|
760
|
-
const taskType = filter.taskType ?? filter.task_type;
|
|
761
|
-
return this._metrics.filter((m) => {
|
|
762
|
-
if (storyKey !== void 0 && m.storyKey !== storyKey) return false;
|
|
763
|
-
if (taskType !== void 0 && m.taskType !== taskType) return false;
|
|
764
|
-
if (filter.sprint !== void 0 && m.sprint !== filter.sprint) return false;
|
|
765
|
-
if (filter.dateFrom !== void 0 && m.recordedAt !== void 0 && m.recordedAt < filter.dateFrom) return false;
|
|
766
|
-
if (filter.dateTo !== void 0 && m.recordedAt !== void 0 && m.recordedAt > filter.dateTo) return false;
|
|
767
|
-
if (filter.since !== void 0 && m.recordedAt !== void 0 && m.recordedAt < filter.since) return false;
|
|
768
|
-
return true;
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
/**
|
|
772
|
-
* Persist an arbitrary key-value metric for a run.
|
|
773
|
-
* Stored in memory AND written to `{basePath}/kv-metrics.json` when basePath is set.
|
|
774
|
-
*/
|
|
775
|
-
async setMetric(runId, key, value) {
|
|
776
|
-
let runMap = this._kvMetrics.get(runId);
|
|
777
|
-
if (runMap === void 0) {
|
|
778
|
-
runMap = new Map();
|
|
779
|
-
this._kvMetrics.set(runId, runMap);
|
|
780
|
-
}
|
|
781
|
-
runMap.set(key, value);
|
|
782
|
-
if (this._basePath !== void 0) await this._flushKvMetrics();
|
|
783
|
-
}
|
|
784
|
-
/**
|
|
785
|
-
* Retrieve a previously stored key-value metric for a run.
|
|
786
|
-
* Reads from in-memory cache, falling back to the JSON file when basePath is set.
|
|
787
|
-
*/
|
|
788
|
-
async getMetric(runId, key) {
|
|
789
|
-
const inMemory = this._kvMetrics.get(runId)?.get(key);
|
|
790
|
-
if (inMemory !== void 0) return inMemory;
|
|
791
|
-
if (this._basePath !== void 0) try {
|
|
792
|
-
const filePath = join$1(this._basePath, "kv-metrics.json");
|
|
793
|
-
const content = await readFile$1(filePath, "utf-8");
|
|
794
|
-
const parsed = JSON.parse(content);
|
|
795
|
-
return parsed[runId]?.[key] ?? void 0;
|
|
796
|
-
} catch {}
|
|
797
|
-
return void 0;
|
|
798
|
-
}
|
|
799
|
-
/** Serialize the in-memory kv metrics map to JSON on disk. */
|
|
800
|
-
async _flushKvMetrics() {
|
|
801
|
-
if (this._basePath === void 0) return;
|
|
802
|
-
const serialized = {};
|
|
803
|
-
for (const [runId, runMap] of this._kvMetrics) {
|
|
804
|
-
serialized[runId] = {};
|
|
805
|
-
for (const [key, value] of runMap) serialized[runId][key] = value;
|
|
806
|
-
}
|
|
807
|
-
const filePath = join$1(this._basePath, "kv-metrics.json");
|
|
808
|
-
await writeFile$1(filePath, JSON.stringify(serialized, null, 2), "utf-8");
|
|
809
|
-
}
|
|
810
|
-
async getContracts(storyKey) {
|
|
811
|
-
return this._contracts.get(storyKey) ?? [];
|
|
812
|
-
}
|
|
813
|
-
async setContracts(storyKey, contracts) {
|
|
814
|
-
this._contracts.set(storyKey, contracts.map((c) => ({ ...c })));
|
|
815
|
-
}
|
|
816
|
-
async queryContracts(filter) {
|
|
817
|
-
const all = [];
|
|
818
|
-
for (const records of this._contracts.values()) for (const r of records) all.push(r);
|
|
819
|
-
return all.filter((r) => {
|
|
820
|
-
if (filter?.storyKey !== void 0 && r.storyKey !== filter.storyKey) return false;
|
|
821
|
-
if (filter?.direction !== void 0 && r.direction !== filter.direction) return false;
|
|
822
|
-
return true;
|
|
823
|
-
});
|
|
824
|
-
}
|
|
825
|
-
async setContractVerification(storyKey, results) {
|
|
826
|
-
this._contractVerifications.set(storyKey, results.map((r) => ({ ...r })));
|
|
827
|
-
if (this._basePath !== void 0) {
|
|
828
|
-
const serialized = {};
|
|
829
|
-
for (const [key, records] of this._contractVerifications) serialized[key] = records;
|
|
830
|
-
const filePath = join$1(this._basePath, "contract-verifications.json");
|
|
831
|
-
await writeFile$1(filePath, JSON.stringify(serialized, null, 2), "utf-8");
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
async getContractVerification(storyKey) {
|
|
835
|
-
return this._contractVerifications.get(storyKey) ?? [];
|
|
836
|
-
}
|
|
837
|
-
async branchForStory(_storyKey) {}
|
|
838
|
-
async mergeStory(_storyKey) {}
|
|
839
|
-
async rollbackStory(_storyKey) {}
|
|
840
|
-
async diffStory(storyKey) {
|
|
841
|
-
return {
|
|
842
|
-
storyKey,
|
|
843
|
-
tables: []
|
|
844
|
-
};
|
|
845
|
-
}
|
|
846
|
-
async getHistory(_limit) {
|
|
847
|
-
return [];
|
|
848
|
-
}
|
|
849
|
-
};
|
|
850
|
-
|
|
851
|
-
//#endregion
|
|
852
|
-
//#region src/modules/state/errors.ts
|
|
853
|
-
/**
|
|
854
|
-
* Typed error classes for the Dolt state store.
|
|
855
|
-
*/
|
|
856
|
-
var StateStoreError = class extends Error {
|
|
857
|
-
code;
|
|
858
|
-
constructor(code, message) {
|
|
859
|
-
super(message);
|
|
860
|
-
this.name = "StateStoreError";
|
|
861
|
-
this.code = code;
|
|
862
|
-
}
|
|
863
|
-
};
|
|
864
|
-
var DoltMergeConflictError = class extends StateStoreError {
|
|
865
|
-
table;
|
|
866
|
-
conflictingKeys;
|
|
867
|
-
rowKey;
|
|
868
|
-
ourValue;
|
|
869
|
-
theirValue;
|
|
870
|
-
constructor(table, conflictingKeys, options) {
|
|
871
|
-
super("DOLT_MERGE_CONFLICT", `Merge conflict in table '${table}' on keys: ${conflictingKeys.join(", ")}`);
|
|
872
|
-
this.name = "DoltMergeConflictError";
|
|
873
|
-
this.table = table;
|
|
874
|
-
this.conflictingKeys = conflictingKeys;
|
|
875
|
-
if (options) {
|
|
876
|
-
this.rowKey = options.rowKey;
|
|
877
|
-
this.ourValue = options.ourValue;
|
|
878
|
-
this.theirValue = options.theirValue;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
};
|
|
882
|
-
/** Alias for DoltMergeConflictError — used by orchestrator branch lifecycle. */
|
|
883
|
-
const DoltMergeConflict = DoltMergeConflictError;
|
|
884
|
-
|
|
885
|
-
//#endregion
|
|
886
|
-
//#region src/modules/state/dolt-store.ts
|
|
887
|
-
const log = createLogger("modules:state:dolt");
|
|
888
|
-
/**
|
|
889
|
-
* Validate that a story key matches the expected pattern (e.g. "26-7", "1-1a", "NEW-26", "E6").
|
|
890
|
-
* Prevents SQL injection via string-interpolated identifiers.
|
|
891
|
-
*/
|
|
892
|
-
const STORY_KEY_PATTERN = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)?$/;
|
|
893
|
-
function assertValidStoryKey(storyKey) {
|
|
894
|
-
if (!STORY_KEY_PATTERN.test(storyKey)) throw new DoltQueryError("assertValidStoryKey", `Invalid story key: '${storyKey}'. Must match pattern <key> or <epic>-<story> (e.g. "E6", "10-1", "1-1a", "NEW-26").`);
|
|
895
|
-
}
|
|
896
|
-
/**
|
|
897
|
-
* Dolt-backed implementation of the StateStore interface.
|
|
898
|
-
*
|
|
899
|
-
* Constructor accepts a deps object for DI: `{ repoPath, client }`.
|
|
900
|
-
* Call `initialize()` before any CRUD operations.
|
|
901
|
-
*/
|
|
902
|
-
var DoltStateStore = class DoltStateStore {
|
|
903
|
-
_repoPath;
|
|
904
|
-
_client;
|
|
905
|
-
_storyBranches = new Map();
|
|
906
|
-
constructor(options) {
|
|
907
|
-
this._repoPath = options.repoPath;
|
|
908
|
-
this._client = options.client;
|
|
909
|
-
}
|
|
910
|
-
/**
|
|
911
|
-
* Return the branch name for a story if one has been created via branchForStory(),
|
|
912
|
-
* or undefined to use the default (main) branch.
|
|
913
|
-
*/
|
|
914
|
-
_branchFor(storyKey) {
|
|
915
|
-
return this._storyBranches.get(storyKey);
|
|
916
|
-
}
|
|
917
|
-
async initialize() {
|
|
918
|
-
await this._client.connect();
|
|
919
|
-
await this._runMigrations();
|
|
920
|
-
await this.flush("substrate: schema migrations");
|
|
921
|
-
log.debug("DoltStateStore initialized at %s", this._repoPath);
|
|
922
|
-
}
|
|
923
|
-
async close() {
|
|
924
|
-
await this._client.close();
|
|
925
|
-
}
|
|
926
|
-
async _runMigrations() {
|
|
927
|
-
const ddl = [
|
|
928
|
-
`CREATE TABLE IF NOT EXISTS stories (
|
|
929
|
-
story_key VARCHAR(100) NOT NULL,
|
|
930
|
-
phase VARCHAR(30) NOT NULL DEFAULT 'PENDING',
|
|
931
|
-
review_cycles INT NOT NULL DEFAULT 0,
|
|
932
|
-
last_verdict VARCHAR(64) NULL,
|
|
933
|
-
error TEXT NULL,
|
|
934
|
-
started_at VARCHAR(64) NULL,
|
|
935
|
-
completed_at VARCHAR(64) NULL,
|
|
936
|
-
sprint VARCHAR(50) NULL,
|
|
937
|
-
PRIMARY KEY (story_key)
|
|
938
|
-
)`,
|
|
939
|
-
`CREATE TABLE IF NOT EXISTS metrics (
|
|
940
|
-
id BIGINT NOT NULL AUTO_INCREMENT,
|
|
941
|
-
story_key VARCHAR(100) NOT NULL,
|
|
942
|
-
task_type VARCHAR(100) NOT NULL,
|
|
943
|
-
model VARCHAR(100) NULL,
|
|
944
|
-
tokens_in BIGINT NULL,
|
|
945
|
-
tokens_out BIGINT NULL,
|
|
946
|
-
cache_read_tokens BIGINT NULL,
|
|
947
|
-
cost_usd DOUBLE NULL,
|
|
948
|
-
wall_clock_ms BIGINT NULL,
|
|
949
|
-
review_cycles INT NULL,
|
|
950
|
-
stall_count INT NULL,
|
|
951
|
-
result VARCHAR(30) NULL,
|
|
952
|
-
recorded_at VARCHAR(64) NULL,
|
|
953
|
-
sprint VARCHAR(50) NULL,
|
|
954
|
-
PRIMARY KEY (id)
|
|
955
|
-
)`,
|
|
956
|
-
`CREATE TABLE IF NOT EXISTS contracts (
|
|
957
|
-
story_key VARCHAR(100) NOT NULL,
|
|
958
|
-
contract_name VARCHAR(200) NOT NULL,
|
|
959
|
-
direction VARCHAR(20) NOT NULL,
|
|
960
|
-
schema_path VARCHAR(500) NULL,
|
|
961
|
-
transport VARCHAR(200) NULL,
|
|
962
|
-
PRIMARY KEY (story_key, contract_name, direction)
|
|
963
|
-
)`,
|
|
964
|
-
`CREATE TABLE IF NOT EXISTS review_verdicts (
|
|
965
|
-
id BIGINT NOT NULL AUTO_INCREMENT,
|
|
966
|
-
story_key VARCHAR(100) NOT NULL,
|
|
967
|
-
task_type VARCHAR(100) NOT NULL,
|
|
968
|
-
verdict VARCHAR(64) NOT NULL,
|
|
969
|
-
issues_count INT NULL,
|
|
970
|
-
notes TEXT NULL,
|
|
971
|
-
timestamp VARCHAR(64) NULL,
|
|
972
|
-
PRIMARY KEY (id)
|
|
973
|
-
)`
|
|
974
|
-
];
|
|
975
|
-
for (const sql of ddl) await this._client.query(sql);
|
|
976
|
-
try {
|
|
977
|
-
const colRows = await this._client.query(`SHOW COLUMNS FROM repo_map_symbols LIKE 'dependencies'`);
|
|
978
|
-
if (colRows.length === 0) {
|
|
979
|
-
await this._client.query(`ALTER TABLE repo_map_symbols ADD COLUMN dependencies JSON`);
|
|
980
|
-
await this._client.query(`INSERT IGNORE INTO _schema_version (version, description) VALUES (6, 'Add dependencies JSON column to repo_map_symbols (Epic 28-3)')`);
|
|
981
|
-
log.info({
|
|
982
|
-
component: "dolt-state",
|
|
983
|
-
migration: "v5-to-v6",
|
|
984
|
-
column: "dependencies",
|
|
985
|
-
table: "repo_map_symbols"
|
|
986
|
-
}, "Applied migration v5-to-v6: added dependencies column to repo_map_symbols");
|
|
987
|
-
}
|
|
988
|
-
} catch {
|
|
989
|
-
log.debug("Skipping repo_map_symbols migration: table not yet created");
|
|
990
|
-
}
|
|
991
|
-
log.debug("Schema migrations applied");
|
|
992
|
-
}
|
|
993
|
-
/**
|
|
994
|
-
* Commit pending Dolt changes on the current branch.
|
|
995
|
-
* Callers can invoke this after a batch of writes for explicit durability.
|
|
996
|
-
*/
|
|
997
|
-
async flush(message = "substrate: auto-commit") {
|
|
998
|
-
try {
|
|
999
|
-
await this._client.execArgs(["add", "."]);
|
|
1000
|
-
await this._client.execArgs([
|
|
1001
|
-
"commit",
|
|
1002
|
-
"--allow-empty",
|
|
1003
|
-
"-m",
|
|
1004
|
-
message
|
|
1005
|
-
]);
|
|
1006
|
-
log.debug("Dolt flush committed: %s", message);
|
|
1007
|
-
} catch (err) {
|
|
1008
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
1009
|
-
log.warn({ detail }, "Dolt flush failed (non-fatal)");
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
async getStoryState(storyKey) {
|
|
1013
|
-
const rows = await this._client.query("SELECT * FROM stories WHERE story_key = ?", [storyKey]);
|
|
1014
|
-
if (rows.length === 0) return void 0;
|
|
1015
|
-
return this._rowToStory(rows[0]);
|
|
1016
|
-
}
|
|
1017
|
-
async setStoryState(storyKey, state) {
|
|
1018
|
-
const branch = this._branchFor(storyKey);
|
|
1019
|
-
const sql = `REPLACE INTO stories
|
|
1020
|
-
(story_key, phase, review_cycles, last_verdict, error, started_at, completed_at, sprint)
|
|
1021
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
1022
|
-
await this._client.query(sql, [
|
|
1023
|
-
storyKey,
|
|
1024
|
-
state.phase,
|
|
1025
|
-
state.reviewCycles,
|
|
1026
|
-
state.lastVerdict ?? null,
|
|
1027
|
-
state.error ?? null,
|
|
1028
|
-
state.startedAt ?? null,
|
|
1029
|
-
state.completedAt ?? null,
|
|
1030
|
-
state.sprint ?? null
|
|
1031
|
-
], branch);
|
|
1032
|
-
}
|
|
1033
|
-
async queryStories(filter) {
|
|
1034
|
-
const conditions = [];
|
|
1035
|
-
const params = [];
|
|
1036
|
-
if (filter.phase !== void 0) {
|
|
1037
|
-
const phases = Array.isArray(filter.phase) ? filter.phase : [filter.phase];
|
|
1038
|
-
const placeholders = phases.map(() => "?").join(", ");
|
|
1039
|
-
conditions.push(`phase IN (${placeholders})`);
|
|
1040
|
-
params.push(...phases);
|
|
1041
|
-
}
|
|
1042
|
-
if (filter.sprint !== void 0) {
|
|
1043
|
-
conditions.push("sprint = ?");
|
|
1044
|
-
params.push(filter.sprint);
|
|
1045
|
-
}
|
|
1046
|
-
if (filter.storyKey !== void 0) {
|
|
1047
|
-
conditions.push("story_key = ?");
|
|
1048
|
-
params.push(filter.storyKey);
|
|
1049
|
-
}
|
|
1050
|
-
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1051
|
-
const sql = `SELECT * FROM stories ${where} ORDER BY story_key`;
|
|
1052
|
-
const rows = await this._client.query(sql, params);
|
|
1053
|
-
return rows.map((r) => this._rowToStory(r));
|
|
1054
|
-
}
|
|
1055
|
-
_rowToStory(row) {
|
|
1056
|
-
return {
|
|
1057
|
-
storyKey: row.story_key,
|
|
1058
|
-
phase: row.phase,
|
|
1059
|
-
reviewCycles: Number(row.review_cycles),
|
|
1060
|
-
lastVerdict: row.last_verdict ?? void 0,
|
|
1061
|
-
error: row.error ?? void 0,
|
|
1062
|
-
startedAt: row.started_at ?? void 0,
|
|
1063
|
-
completedAt: row.completed_at ?? void 0,
|
|
1064
|
-
sprint: row.sprint ?? void 0
|
|
1065
|
-
};
|
|
1066
|
-
}
|
|
1067
|
-
async recordMetric(metric) {
|
|
1068
|
-
const branch = this._branchFor(metric.storyKey);
|
|
1069
|
-
const recordedAt = metric.recordedAt ?? metric.timestamp ?? new Date().toISOString();
|
|
1070
|
-
const sql = `INSERT INTO metrics
|
|
1071
|
-
(story_key, task_type, model, tokens_in, tokens_out, cache_read_tokens,
|
|
1072
|
-
cost_usd, wall_clock_ms, review_cycles, stall_count, result, recorded_at, sprint)
|
|
1073
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
1074
|
-
await this._client.query(sql, [
|
|
1075
|
-
metric.storyKey,
|
|
1076
|
-
metric.taskType,
|
|
1077
|
-
metric.model ?? null,
|
|
1078
|
-
metric.tokensIn ?? null,
|
|
1079
|
-
metric.tokensOut ?? null,
|
|
1080
|
-
metric.cacheReadTokens ?? null,
|
|
1081
|
-
metric.costUsd ?? null,
|
|
1082
|
-
metric.wallClockMs ?? null,
|
|
1083
|
-
metric.reviewCycles ?? null,
|
|
1084
|
-
metric.stallCount ?? null,
|
|
1085
|
-
metric.result ?? null,
|
|
1086
|
-
recordedAt,
|
|
1087
|
-
metric.sprint ?? null
|
|
1088
|
-
], branch);
|
|
1089
|
-
}
|
|
1090
|
-
async queryMetrics(filter) {
|
|
1091
|
-
const conditions = [];
|
|
1092
|
-
const params = [];
|
|
1093
|
-
const storyKey = filter.storyKey ?? filter.story_key;
|
|
1094
|
-
const taskType = filter.taskType ?? filter.task_type;
|
|
1095
|
-
if (storyKey !== void 0) {
|
|
1096
|
-
conditions.push("story_key = ?");
|
|
1097
|
-
params.push(storyKey);
|
|
1098
|
-
}
|
|
1099
|
-
if (taskType !== void 0) {
|
|
1100
|
-
conditions.push("task_type = ?");
|
|
1101
|
-
params.push(taskType);
|
|
1102
|
-
}
|
|
1103
|
-
if (filter.sprint !== void 0) {
|
|
1104
|
-
conditions.push("sprint = ?");
|
|
1105
|
-
params.push(filter.sprint);
|
|
1106
|
-
}
|
|
1107
|
-
if (filter.dateFrom !== void 0) {
|
|
1108
|
-
conditions.push("recorded_at >= ?");
|
|
1109
|
-
params.push(filter.dateFrom);
|
|
1110
|
-
}
|
|
1111
|
-
if (filter.dateTo !== void 0) {
|
|
1112
|
-
conditions.push("recorded_at <= ?");
|
|
1113
|
-
params.push(filter.dateTo);
|
|
1114
|
-
}
|
|
1115
|
-
if (filter.since !== void 0) {
|
|
1116
|
-
conditions.push("recorded_at >= ?");
|
|
1117
|
-
params.push(filter.since);
|
|
1118
|
-
}
|
|
1119
|
-
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1120
|
-
if (filter.aggregate) {
|
|
1121
|
-
const sql$1 = `SELECT task_type,
|
|
1122
|
-
AVG(cost_usd) AS avg_cost_usd,
|
|
1123
|
-
SUM(tokens_in) AS sum_tokens_in,
|
|
1124
|
-
SUM(tokens_out) AS sum_tokens_out,
|
|
1125
|
-
COUNT(*) AS count
|
|
1126
|
-
FROM metrics ${where} GROUP BY task_type ORDER BY task_type`;
|
|
1127
|
-
const aggRows = await this._client.query(sql$1, params);
|
|
1128
|
-
return aggRows.map((r) => this._aggregateRowToMetric(r));
|
|
1129
|
-
}
|
|
1130
|
-
const sql = `SELECT * FROM metrics ${where} ORDER BY id`;
|
|
1131
|
-
const rows = await this._client.query(sql, params);
|
|
1132
|
-
return rows.map((r) => this._rowToMetric(r));
|
|
1133
|
-
}
|
|
1134
|
-
_aggregateRowToMetric(row) {
|
|
1135
|
-
return {
|
|
1136
|
-
storyKey: "",
|
|
1137
|
-
taskType: row.task_type,
|
|
1138
|
-
costUsd: row.avg_cost_usd ?? void 0,
|
|
1139
|
-
tokensIn: row.sum_tokens_in ?? void 0,
|
|
1140
|
-
tokensOut: row.sum_tokens_out ?? void 0,
|
|
1141
|
-
count: row.count,
|
|
1142
|
-
result: "aggregate"
|
|
1143
|
-
};
|
|
1144
|
-
}
|
|
1145
|
-
_rowToMetric(row) {
|
|
1146
|
-
return {
|
|
1147
|
-
storyKey: row.story_key,
|
|
1148
|
-
taskType: row.task_type,
|
|
1149
|
-
model: row.model ?? void 0,
|
|
1150
|
-
tokensIn: row.tokens_in ?? void 0,
|
|
1151
|
-
tokensOut: row.tokens_out ?? void 0,
|
|
1152
|
-
cacheReadTokens: row.cache_read_tokens ?? void 0,
|
|
1153
|
-
costUsd: row.cost_usd ?? void 0,
|
|
1154
|
-
wallClockMs: row.wall_clock_ms ?? void 0,
|
|
1155
|
-
reviewCycles: row.review_cycles ?? void 0,
|
|
1156
|
-
stallCount: row.stall_count ?? void 0,
|
|
1157
|
-
result: row.result ?? void 0,
|
|
1158
|
-
recordedAt: row.recorded_at ?? void 0,
|
|
1159
|
-
sprint: row.sprint ?? void 0,
|
|
1160
|
-
timestamp: row.timestamp ?? row.recorded_at ?? void 0
|
|
1161
|
-
};
|
|
1162
|
-
}
|
|
1163
|
-
async getContracts(storyKey) {
|
|
1164
|
-
const rows = await this._client.query("SELECT * FROM contracts WHERE story_key = ? ORDER BY contract_name", [storyKey]);
|
|
1165
|
-
return rows.map((r) => this._rowToContract(r));
|
|
1166
|
-
}
|
|
1167
|
-
async setContracts(storyKey, contracts) {
|
|
1168
|
-
const branch = this._branchFor(storyKey);
|
|
1169
|
-
await this._client.query("DELETE FROM contracts WHERE story_key = ?", [storyKey], branch);
|
|
1170
|
-
for (const c of contracts) await this._client.query(`INSERT INTO contracts (story_key, contract_name, direction, schema_path, transport)
|
|
1171
|
-
VALUES (?, ?, ?, ?, ?)`, [
|
|
1172
|
-
c.storyKey,
|
|
1173
|
-
c.contractName,
|
|
1174
|
-
c.direction,
|
|
1175
|
-
c.schemaPath,
|
|
1176
|
-
c.transport ?? null
|
|
1177
|
-
], branch);
|
|
1178
|
-
}
|
|
1179
|
-
_rowToContract(row) {
|
|
1180
|
-
return {
|
|
1181
|
-
storyKey: row.story_key,
|
|
1182
|
-
contractName: row.contract_name,
|
|
1183
|
-
direction: row.direction,
|
|
1184
|
-
schemaPath: row.schema_path,
|
|
1185
|
-
transport: row.transport ?? void 0
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
async queryContracts(filter) {
|
|
1189
|
-
const conditions = [];
|
|
1190
|
-
const params = [];
|
|
1191
|
-
if (filter?.storyKey !== void 0) {
|
|
1192
|
-
conditions.push("story_key = ?");
|
|
1193
|
-
params.push(filter.storyKey);
|
|
1194
|
-
}
|
|
1195
|
-
if (filter?.direction !== void 0) {
|
|
1196
|
-
conditions.push("direction = ?");
|
|
1197
|
-
params.push(filter.direction);
|
|
1198
|
-
}
|
|
1199
|
-
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1200
|
-
const sql = `SELECT * FROM contracts ${where} ORDER BY story_key, contract_name`;
|
|
1201
|
-
const rows = await this._client.query(sql, params);
|
|
1202
|
-
return rows.map((r) => this._rowToContract(r));
|
|
1203
|
-
}
|
|
1204
|
-
async setContractVerification(storyKey, results) {
|
|
1205
|
-
const branch = this._branchFor(storyKey);
|
|
1206
|
-
await this._client.query(`DELETE FROM review_verdicts WHERE story_key = ? AND task_type = 'contract-verification'`, [storyKey], branch);
|
|
1207
|
-
const failCount = results.filter((r) => r.verdict === "fail").length;
|
|
1208
|
-
for (const r of results) await this._client.query(`INSERT INTO review_verdicts (story_key, task_type, verdict, issues_count, notes, timestamp)
|
|
1209
|
-
VALUES (?, 'contract-verification', ?, ?, ?, ?)`, [
|
|
1210
|
-
storyKey,
|
|
1211
|
-
r.verdict,
|
|
1212
|
-
failCount,
|
|
1213
|
-
JSON.stringify({
|
|
1214
|
-
contractName: r.contractName,
|
|
1215
|
-
mismatchDescription: r.mismatchDescription
|
|
1216
|
-
}),
|
|
1217
|
-
r.verifiedAt
|
|
1218
|
-
], branch);
|
|
1219
|
-
}
|
|
1220
|
-
async getContractVerification(storyKey) {
|
|
1221
|
-
const rows = await this._client.query(`SELECT * FROM review_verdicts WHERE story_key = ? AND task_type = 'contract-verification' ORDER BY timestamp DESC`, [storyKey]);
|
|
1222
|
-
return rows.map((row) => {
|
|
1223
|
-
let contractName = "";
|
|
1224
|
-
let mismatchDescription;
|
|
1225
|
-
if (row.notes !== null) try {
|
|
1226
|
-
const parsed = JSON.parse(row.notes);
|
|
1227
|
-
if (typeof parsed.contractName === "string") contractName = parsed.contractName;
|
|
1228
|
-
if (typeof parsed.mismatchDescription === "string") mismatchDescription = parsed.mismatchDescription;
|
|
1229
|
-
} catch {}
|
|
1230
|
-
return {
|
|
1231
|
-
storyKey: row.story_key,
|
|
1232
|
-
contractName,
|
|
1233
|
-
verdict: row.verdict,
|
|
1234
|
-
...mismatchDescription !== void 0 ? { mismatchDescription } : {},
|
|
1235
|
-
verifiedAt: row.timestamp ?? new Date().toISOString()
|
|
1236
|
-
};
|
|
1237
|
-
});
|
|
1238
|
-
}
|
|
1239
|
-
async branchForStory(storyKey) {
|
|
1240
|
-
assertValidStoryKey(storyKey);
|
|
1241
|
-
const branchName = `story/${storyKey}`;
|
|
1242
|
-
try {
|
|
1243
|
-
await this._client.query(`CALL DOLT_BRANCH('${branchName}')`, [], "main");
|
|
1244
|
-
this._storyBranches.set(storyKey, branchName);
|
|
1245
|
-
log.debug("Created Dolt branch %s for story %s", branchName, storyKey);
|
|
1246
|
-
} catch (err) {
|
|
1247
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
1248
|
-
throw new DoltQueryError(`CALL DOLT_BRANCH('${branchName}')`, detail);
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
async mergeStory(storyKey) {
|
|
1252
|
-
assertValidStoryKey(storyKey);
|
|
1253
|
-
const branchName = this._storyBranches.get(storyKey);
|
|
1254
|
-
if (branchName === void 0) {
|
|
1255
|
-
log.warn({ storyKey }, "mergeStory called but no branch registered — no-op");
|
|
1256
|
-
return;
|
|
1257
|
-
}
|
|
1258
|
-
try {
|
|
1259
|
-
try {
|
|
1260
|
-
await this._client.query(`CALL DOLT_ADD('-A')`, [], branchName);
|
|
1261
|
-
await this._client.query(`CALL DOLT_COMMIT('-m', 'Story ${storyKey}: pre-merge commit', '--allow-empty')`, [], branchName);
|
|
1262
|
-
} catch {}
|
|
1263
|
-
try {
|
|
1264
|
-
await this._client.query(`CALL DOLT_ADD('-A')`, [], "main");
|
|
1265
|
-
await this._client.query(`CALL DOLT_COMMIT('-m', 'substrate: pre-merge auto-commit', '--allow-empty')`, [], "main");
|
|
1266
|
-
} catch {}
|
|
1267
|
-
const mergeRows = await this._client.query(`CALL DOLT_MERGE('${branchName}')`, [], "main");
|
|
1268
|
-
const mergeResult = mergeRows[0];
|
|
1269
|
-
if (mergeResult && (mergeResult.conflicts ?? 0) > 0) {
|
|
1270
|
-
let table = "stories";
|
|
1271
|
-
let rowKey = "unknown";
|
|
1272
|
-
let ourValue;
|
|
1273
|
-
let theirValue;
|
|
1274
|
-
try {
|
|
1275
|
-
const conflictRows = await this._client.query(`SELECT * FROM dolt_conflicts_stories LIMIT 1`, [], "main");
|
|
1276
|
-
if (conflictRows.length > 0) {
|
|
1277
|
-
const row = conflictRows[0];
|
|
1278
|
-
rowKey = String(row["base_story_key"] ?? row["our_story_key"] ?? "unknown");
|
|
1279
|
-
ourValue = JSON.stringify(row["our_status"] ?? row);
|
|
1280
|
-
theirValue = JSON.stringify(row["their_status"] ?? row);
|
|
1281
|
-
}
|
|
1282
|
-
} catch {}
|
|
1283
|
-
this._storyBranches.delete(storyKey);
|
|
1284
|
-
throw new DoltMergeConflictError(table, [rowKey], {
|
|
1285
|
-
rowKey,
|
|
1286
|
-
ourValue,
|
|
1287
|
-
theirValue
|
|
1288
|
-
});
|
|
1289
|
-
}
|
|
1290
|
-
try {
|
|
1291
|
-
await this._client.query(`CALL DOLT_COMMIT('-m', 'Merge story ${storyKey}: COMPLETE')`, [], "main");
|
|
1292
|
-
} catch (commitErr) {
|
|
1293
|
-
const msg = commitErr instanceof Error ? commitErr.message : String(commitErr);
|
|
1294
|
-
if (!msg.includes("nothing to commit")) throw commitErr;
|
|
1295
|
-
}
|
|
1296
|
-
this._storyBranches.delete(storyKey);
|
|
1297
|
-
log.debug("Merged branch %s into main for story %s", branchName, storyKey);
|
|
1298
|
-
} catch (err) {
|
|
1299
|
-
if (err instanceof DoltMergeConflictError) throw err;
|
|
1300
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
1301
|
-
throw new DoltQueryError(`CALL DOLT_MERGE('${branchName}')`, detail);
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
async rollbackStory(storyKey) {
|
|
1305
|
-
assertValidStoryKey(storyKey);
|
|
1306
|
-
const branchName = this._storyBranches.get(storyKey);
|
|
1307
|
-
if (branchName === void 0) {
|
|
1308
|
-
log.warn({ storyKey }, "rollbackStory called but no branch registered — no-op");
|
|
1309
|
-
return;
|
|
1310
|
-
}
|
|
1311
|
-
try {
|
|
1312
|
-
await this._client.query(`CALL DOLT_BRANCH('-D', '${branchName}')`, [], "main");
|
|
1313
|
-
this._storyBranches.delete(storyKey);
|
|
1314
|
-
log.debug("Rolled back (deleted) branch %s for story %s", branchName, storyKey);
|
|
1315
|
-
} catch (err) {
|
|
1316
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
1317
|
-
log.warn({
|
|
1318
|
-
detail,
|
|
1319
|
-
storyKey,
|
|
1320
|
-
branchName
|
|
1321
|
-
}, "rollbackStory failed (non-fatal)");
|
|
1322
|
-
this._storyBranches.delete(storyKey);
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
/**
|
|
1326
|
-
* Tables queried by diffStory(). Each table is checked for row-level changes
|
|
1327
|
-
* via SELECT * FROM DOLT_DIFF('main', branchName, tableName).
|
|
1328
|
-
*/
|
|
1329
|
-
static DIFF_TABLES = [
|
|
1330
|
-
"stories",
|
|
1331
|
-
"contracts",
|
|
1332
|
-
"metrics",
|
|
1333
|
-
"dispatch_log",
|
|
1334
|
-
"build_results",
|
|
1335
|
-
"review_verdicts"
|
|
1336
|
-
];
|
|
1337
|
-
async diffStory(storyKey) {
|
|
1338
|
-
assertValidStoryKey(storyKey);
|
|
1339
|
-
const branchName = this._storyBranches.get(storyKey);
|
|
1340
|
-
if (branchName === void 0) return this._diffMergedStory(storyKey);
|
|
1341
|
-
try {
|
|
1342
|
-
await this._client.query(`CALL DOLT_ADD('-A')`, [], branchName);
|
|
1343
|
-
await this._client.query(`CALL DOLT_COMMIT('-m', 'Story ${storyKey}: pre-diff snapshot', '--allow-empty')`, [], branchName);
|
|
1344
|
-
} catch {}
|
|
1345
|
-
return this._diffRange("main", branchName, storyKey);
|
|
1346
|
-
}
|
|
1347
|
-
/**
|
|
1348
|
-
* Diff a merged story by finding its merge commit in the Dolt log.
|
|
1349
|
-
* Queries the `dolt_log` system table for commits referencing the story,
|
|
1350
|
-
* then diffs `<hash>~1` vs `<hash>` for row-level changes.
|
|
1351
|
-
*/
|
|
1352
|
-
async _diffMergedStory(storyKey) {
|
|
1353
|
-
try {
|
|
1354
|
-
const rows = await this._client.query(`SELECT commit_hash FROM dolt_log WHERE message LIKE ? LIMIT 1`, [`%${storyKey}%`]);
|
|
1355
|
-
if (rows.length === 0) return {
|
|
1356
|
-
storyKey,
|
|
1357
|
-
tables: []
|
|
1358
|
-
};
|
|
1359
|
-
const hash = String(rows[0].commit_hash);
|
|
1360
|
-
if (!hash) return {
|
|
1361
|
-
storyKey,
|
|
1362
|
-
tables: []
|
|
1363
|
-
};
|
|
1364
|
-
return this._diffRange(`${hash}~1`, hash, storyKey);
|
|
1365
|
-
} catch {
|
|
1366
|
-
return {
|
|
1367
|
-
storyKey,
|
|
1368
|
-
tables: []
|
|
1369
|
-
};
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
/**
|
|
1373
|
-
* Compute row-level diffs between two Dolt revisions (branches or commit hashes)
|
|
1374
|
-
* across all tracked tables.
|
|
1375
|
-
*/
|
|
1376
|
-
async _diffRange(fromRef, toRef, storyKey) {
|
|
1377
|
-
const tableDiffs = [];
|
|
1378
|
-
for (const table of DoltStateStore.DIFF_TABLES) try {
|
|
1379
|
-
const rows = await this._client.query(`SELECT * FROM DOLT_DIFF('${fromRef}', '${toRef}', '${table}')`, [], "main");
|
|
1380
|
-
if (rows.length === 0) continue;
|
|
1381
|
-
const added = [];
|
|
1382
|
-
const modified = [];
|
|
1383
|
-
const deleted = [];
|
|
1384
|
-
for (const row of rows) {
|
|
1385
|
-
const diffType = row["diff_type"];
|
|
1386
|
-
const rowKey = this._extractRowKey(row);
|
|
1387
|
-
const before = this._extractPrefixedFields(row, "before_");
|
|
1388
|
-
const after = this._extractPrefixedFields(row, "after_");
|
|
1389
|
-
const diffRow = {
|
|
1390
|
-
rowKey,
|
|
1391
|
-
...before !== void 0 && { before },
|
|
1392
|
-
...after !== void 0 && { after }
|
|
1393
|
-
};
|
|
1394
|
-
if (diffType === "added") added.push(diffRow);
|
|
1395
|
-
else if (diffType === "modified") modified.push(diffRow);
|
|
1396
|
-
else if (diffType === "removed") deleted.push(diffRow);
|
|
1397
|
-
}
|
|
1398
|
-
if (added.length > 0 || modified.length > 0 || deleted.length > 0) tableDiffs.push({
|
|
1399
|
-
table,
|
|
1400
|
-
added,
|
|
1401
|
-
modified,
|
|
1402
|
-
deleted
|
|
1403
|
-
});
|
|
1404
|
-
} catch {}
|
|
1405
|
-
return {
|
|
1406
|
-
storyKey,
|
|
1407
|
-
tables: tableDiffs
|
|
1408
|
-
};
|
|
1409
|
-
}
|
|
1410
|
-
/**
|
|
1411
|
-
* Extract a human-readable row key from a DOLT_DIFF result row.
|
|
1412
|
-
* Tries after_ fields first (for added/modified rows), then before_ fields
|
|
1413
|
-
* (for removed rows). Skips commit_hash pseudo-columns.
|
|
1414
|
-
*/
|
|
1415
|
-
_extractRowKey(row) {
|
|
1416
|
-
for (const prefix of ["after_", "before_"]) for (const [key, val] of Object.entries(row)) if (key.startsWith(prefix) && !key.endsWith("_commit_hash") && val !== null && val !== void 0) return String(val);
|
|
1417
|
-
return "unknown";
|
|
1418
|
-
}
|
|
1419
|
-
/**
|
|
1420
|
-
* Extract all fields with a given prefix from a DOLT_DIFF result row,
|
|
1421
|
-
* stripping the prefix from the key names. Returns undefined if no matching
|
|
1422
|
-
* fields are found.
|
|
1423
|
-
*/
|
|
1424
|
-
_extractPrefixedFields(row, prefix) {
|
|
1425
|
-
const result = {};
|
|
1426
|
-
for (const [key, val] of Object.entries(row)) if (key.startsWith(prefix)) result[key.slice(prefix.length)] = val;
|
|
1427
|
-
return Object.keys(result).length > 0 ? result : void 0;
|
|
1428
|
-
}
|
|
1429
|
-
/** In-memory KV store for per-run arbitrary metrics. Not persisted to Dolt. */
|
|
1430
|
-
_kvMetrics = new Map();
|
|
1431
|
-
async setMetric(runId, key, value) {
|
|
1432
|
-
let runMap = this._kvMetrics.get(runId);
|
|
1433
|
-
if (runMap === void 0) {
|
|
1434
|
-
runMap = new Map();
|
|
1435
|
-
this._kvMetrics.set(runId, runMap);
|
|
1436
|
-
}
|
|
1437
|
-
runMap.set(key, value);
|
|
1438
|
-
}
|
|
1439
|
-
async getMetric(runId, key) {
|
|
1440
|
-
return this._kvMetrics.get(runId)?.get(key);
|
|
1441
|
-
}
|
|
1442
|
-
async getHistory(limit) {
|
|
1443
|
-
const effectiveLimit = limit ?? 20;
|
|
1444
|
-
try {
|
|
1445
|
-
const rows = await this._client.query(`SELECT commit_hash, date, message, committer FROM dolt_log LIMIT ?`, [effectiveLimit]);
|
|
1446
|
-
const entries = [];
|
|
1447
|
-
for (const row of rows) {
|
|
1448
|
-
const hash = String(row.commit_hash ?? "");
|
|
1449
|
-
const dateVal = row.date;
|
|
1450
|
-
const timestamp = dateVal instanceof Date ? dateVal.toISOString() : String(dateVal ?? "");
|
|
1451
|
-
const message = String(row.message ?? "");
|
|
1452
|
-
const author = row.committer ? String(row.committer) : void 0;
|
|
1453
|
-
const storyKeyMatch = /story\/([0-9]+-[0-9]+)/i.exec(message);
|
|
1454
|
-
entries.push({
|
|
1455
|
-
hash,
|
|
1456
|
-
timestamp,
|
|
1457
|
-
storyKey: storyKeyMatch ? storyKeyMatch[1] : null,
|
|
1458
|
-
message,
|
|
1459
|
-
author
|
|
1460
|
-
});
|
|
1461
|
-
}
|
|
1462
|
-
return entries;
|
|
1463
|
-
} catch (err) {
|
|
1464
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
1465
|
-
throw new DoltQueryError("getHistory", detail);
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
};
|
|
1469
|
-
|
|
1470
|
-
//#endregion
|
|
1471
|
-
//#region src/modules/state/index.ts
|
|
1472
|
-
const logger$2 = createLogger("state:factory");
|
|
19
|
+
//#region src/utils/git-root.ts
|
|
1473
20
|
/**
|
|
1474
|
-
*
|
|
1475
|
-
* canonical state path under `basePath`.
|
|
21
|
+
* Resolve the main git repository root, even from a linked worktree.
|
|
1476
22
|
*
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1480
|
-
*/
|
|
1481
|
-
function detectDoltAvailableSync(basePath) {
|
|
1482
|
-
const result = spawnSync("dolt", ["version"], { stdio: "ignore" });
|
|
1483
|
-
const binaryFound = result.error == null && result.status === 0;
|
|
1484
|
-
if (!binaryFound) return {
|
|
1485
|
-
available: false,
|
|
1486
|
-
reason: "dolt binary not found on PATH"
|
|
1487
|
-
};
|
|
1488
|
-
const stateDoltDir = join$1(basePath, ".substrate", "state", ".dolt");
|
|
1489
|
-
const repoExists = existsSync(stateDoltDir);
|
|
1490
|
-
if (!repoExists) return {
|
|
1491
|
-
available: false,
|
|
1492
|
-
reason: `Dolt repo not initialised at ${stateDoltDir}`
|
|
1493
|
-
};
|
|
1494
|
-
return {
|
|
1495
|
-
available: true,
|
|
1496
|
-
reason: "dolt binary found and repo initialised"
|
|
1497
|
-
};
|
|
1498
|
-
}
|
|
1499
|
-
/**
|
|
1500
|
-
* Create a StateStore backed by the specified backend.
|
|
23
|
+
* In the main worktree, `--git-common-dir` returns `.git` (relative).
|
|
24
|
+
* In a linked worktree, it returns the absolute path to the main `.git` dir.
|
|
25
|
+
* Either way, `dirname()` of the resolved absolute path yields the repo root.
|
|
1501
26
|
*
|
|
1502
|
-
*
|
|
1503
|
-
* @returns A StateStore instance. Call `initialize()` before use.
|
|
27
|
+
* Falls back to `cwd` if not in a git repo or git is unavailable.
|
|
1504
28
|
*/
|
|
1505
|
-
function
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
const
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
29
|
+
async function resolveMainRepoRoot(cwd = process.cwd()) {
|
|
30
|
+
return new Promise((res) => {
|
|
31
|
+
let stdout = "";
|
|
32
|
+
const proc = spawn("git", ["rev-parse", "--git-common-dir"], {
|
|
33
|
+
cwd,
|
|
34
|
+
stdio: [
|
|
35
|
+
"ignore",
|
|
36
|
+
"pipe",
|
|
37
|
+
"pipe"
|
|
38
|
+
]
|
|
1513
39
|
});
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
40
|
+
if (proc.stdout !== null) proc.stdout.on("data", (chunk) => {
|
|
41
|
+
stdout += chunk.toString("utf-8");
|
|
42
|
+
});
|
|
43
|
+
proc.on("error", () => {
|
|
44
|
+
res(cwd);
|
|
45
|
+
});
|
|
46
|
+
proc.on("close", (code) => {
|
|
47
|
+
if (code !== 0) {
|
|
48
|
+
res(cwd);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const commonDir = stdout.trim();
|
|
52
|
+
if (!commonDir) {
|
|
53
|
+
res(cwd);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const absCommonDir = resolve$1(cwd, commonDir);
|
|
57
|
+
res(dirname$1(absCommonDir));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
1531
60
|
}
|
|
1532
61
|
|
|
1533
62
|
//#endregion
|
|
@@ -2766,8 +1295,8 @@ function resolveGraphPath() {
|
|
|
2766
1295
|
join$1(__dirname, "graphs/sdlc-pipeline.dot")
|
|
2767
1296
|
];
|
|
2768
1297
|
try {
|
|
2769
|
-
const require
|
|
2770
|
-
const sdlcPkgPath = require
|
|
1298
|
+
const require = createRequire(import.meta.url);
|
|
1299
|
+
const sdlcPkgPath = require.resolve("@substrate-ai/sdlc/package.json");
|
|
2771
1300
|
candidates.push(join$1(dirname$1(sdlcPkgPath), "graphs", "sdlc-pipeline.dot"));
|
|
2772
1301
|
} catch {}
|
|
2773
1302
|
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
@@ -6040,7 +4569,12 @@ const ProposalSchema = z.object({
|
|
|
6040
4569
|
z.string()
|
|
6041
4570
|
]),
|
|
6042
4571
|
story_key: z.string().optional(),
|
|
6043
|
-
payload: z.record(z.string(), z.unknown()).optional()
|
|
4572
|
+
payload: z.record(z.string(), z.unknown()).optional(),
|
|
4573
|
+
storyKey: z.string().optional(),
|
|
4574
|
+
rootCause: z.string().optional(),
|
|
4575
|
+
attempts: z.number().int().optional(),
|
|
4576
|
+
suggestedAction: z.string().optional(),
|
|
4577
|
+
blastRadius: z.array(z.string()).optional()
|
|
6044
4578
|
});
|
|
6045
4579
|
/**
|
|
6046
4580
|
* Zod schema for the full run manifest data.
|
|
@@ -6519,6 +5053,64 @@ var RunManifest = class RunManifest {
|
|
|
6519
5053
|
return this._enqueue(() => this._appendRecoveryEntryImpl(entry));
|
|
6520
5054
|
}
|
|
6521
5055
|
/**
|
|
5056
|
+
* Raw implementation — must only be called from within `_enqueue`.
|
|
5057
|
+
*/
|
|
5058
|
+
async _appendProposalImpl(proposal) {
|
|
5059
|
+
let existingData;
|
|
5060
|
+
try {
|
|
5061
|
+
const read = await RunManifest.read(this.runId, this.baseDir, this.doltAdapter);
|
|
5062
|
+
const { generation: _gen, updated_at: _ts,...rest } = read;
|
|
5063
|
+
existingData = rest;
|
|
5064
|
+
} catch {
|
|
5065
|
+
const now = new Date().toISOString();
|
|
5066
|
+
existingData = {
|
|
5067
|
+
run_id: this.runId,
|
|
5068
|
+
cli_flags: {},
|
|
5069
|
+
story_scope: [],
|
|
5070
|
+
supervisor_pid: null,
|
|
5071
|
+
supervisor_session_id: null,
|
|
5072
|
+
per_story_state: {},
|
|
5073
|
+
recovery_history: [],
|
|
5074
|
+
cost_accumulation: {
|
|
5075
|
+
per_story: {},
|
|
5076
|
+
run_total: 0
|
|
5077
|
+
},
|
|
5078
|
+
pending_proposals: [],
|
|
5079
|
+
created_at: now
|
|
5080
|
+
};
|
|
5081
|
+
}
|
|
5082
|
+
const targetKey = proposal.storyKey ?? proposal.story_key;
|
|
5083
|
+
if (targetKey !== void 0) {
|
|
5084
|
+
const alreadyPresent = existingData.pending_proposals.some((p) => {
|
|
5085
|
+
const existingKey = p.storyKey ?? p.story_key;
|
|
5086
|
+
return existingKey === targetKey;
|
|
5087
|
+
});
|
|
5088
|
+
if (alreadyPresent) return;
|
|
5089
|
+
}
|
|
5090
|
+
await this._writeImpl({
|
|
5091
|
+
...existingData,
|
|
5092
|
+
pending_proposals: [...existingData.pending_proposals, proposal]
|
|
5093
|
+
});
|
|
5094
|
+
}
|
|
5095
|
+
/**
|
|
5096
|
+
* Atomically append a proposal to `pending_proposals`, deduplicating by storyKey.
|
|
5097
|
+
*
|
|
5098
|
+
* If a proposal with the same `storyKey` (or `story_key`) is already present in
|
|
5099
|
+
* `pending_proposals`, the call is a silent no-op (idempotent). This guards
|
|
5100
|
+
* against duplicate proposals from concurrent or repeated recovery invocations.
|
|
5101
|
+
*
|
|
5102
|
+
* Enqueues the operation via `_enqueue` so concurrent calls are serialized.
|
|
5103
|
+
* Non-fatal: callers MUST wrap in `.catch((err) => logger.warn(...))`.
|
|
5104
|
+
* The pipeline must never abort due to a manifest write failure.
|
|
5105
|
+
*
|
|
5106
|
+
* Story 73-1 (Recovery Engine). Canonical manifest helper per AC3.
|
|
5107
|
+
*
|
|
5108
|
+
* @param proposal - Proposal to append (id, created_at, description, type + Epic 73 fields)
|
|
5109
|
+
*/
|
|
5110
|
+
async appendProposal(proposal) {
|
|
5111
|
+
return this._enqueue(() => this._appendProposalImpl(proposal));
|
|
5112
|
+
}
|
|
5113
|
+
/**
|
|
6522
5114
|
* Create a new manifest with `generation: 0` and write it.
|
|
6523
5115
|
* Returns a bound `RunManifest` instance.
|
|
6524
5116
|
*/
|
|
@@ -6854,11 +5446,11 @@ var SupervisorLock = class {
|
|
|
6854
5446
|
mode = null;
|
|
6855
5447
|
/** File handle held open to maintain the advisory lock (flock mode only). */
|
|
6856
5448
|
lockHandle = null;
|
|
6857
|
-
constructor(runId, manifest, logger$
|
|
5449
|
+
constructor(runId, manifest, logger$1) {
|
|
6858
5450
|
this.runId = runId;
|
|
6859
5451
|
this.manifest = manifest;
|
|
6860
5452
|
this.baseDir = manifest.baseDir;
|
|
6861
|
-
this.logger = logger$
|
|
5453
|
+
this.logger = logger$1 ?? defaultLogger;
|
|
6862
5454
|
}
|
|
6863
5455
|
/** Advisory lock file path (primary path). */
|
|
6864
5456
|
get lockPath() {
|
|
@@ -6905,19 +5497,19 @@ var SupervisorLock = class {
|
|
|
6905
5497
|
existingPid = data.supervisor_pid;
|
|
6906
5498
|
} catch {}
|
|
6907
5499
|
if (existingPid === null) {
|
|
6908
|
-
await unlink(this.lockPath).catch(() => void 0);
|
|
5500
|
+
await unlink$1(this.lockPath).catch(() => void 0);
|
|
6909
5501
|
await this.acquire(pid, sessionId, opts);
|
|
6910
5502
|
return;
|
|
6911
5503
|
}
|
|
6912
5504
|
const isAlive = this.isPidAlive(existingPid);
|
|
6913
5505
|
if (!isAlive) {
|
|
6914
|
-
await unlink(this.lockPath).catch(() => void 0);
|
|
5506
|
+
await unlink$1(this.lockPath).catch(() => void 0);
|
|
6915
5507
|
await this.acquire(pid, sessionId, opts);
|
|
6916
5508
|
return;
|
|
6917
5509
|
}
|
|
6918
5510
|
if (force) {
|
|
6919
5511
|
await this.forceKillOwner(existingPid);
|
|
6920
|
-
await unlink(this.lockPath).catch(() => void 0);
|
|
5512
|
+
await unlink$1(this.lockPath).catch(() => void 0);
|
|
6921
5513
|
await this.acquire(pid, sessionId, opts);
|
|
6922
5514
|
return;
|
|
6923
5515
|
}
|
|
@@ -6939,7 +5531,7 @@ var SupervisorLock = class {
|
|
|
6939
5531
|
} catch {}
|
|
6940
5532
|
this.lockHandle = null;
|
|
6941
5533
|
this.mode = null;
|
|
6942
|
-
await unlink(this.lockPath).catch(() => void 0);
|
|
5534
|
+
await unlink$1(this.lockPath).catch(() => void 0);
|
|
6943
5535
|
throw postOpenErr;
|
|
6944
5536
|
}
|
|
6945
5537
|
}
|
|
@@ -6959,7 +5551,7 @@ var SupervisorLock = class {
|
|
|
6959
5551
|
} catch {}
|
|
6960
5552
|
this.lockHandle = null;
|
|
6961
5553
|
}
|
|
6962
|
-
await unlink(this.lockPath).catch(() => void 0);
|
|
5554
|
+
await unlink$1(this.lockPath).catch(() => void 0);
|
|
6963
5555
|
} else if (this.mode === "pid-file") await this.releaseViaPidFile();
|
|
6964
5556
|
this.mode = null;
|
|
6965
5557
|
await this.manifest.update({
|
|
@@ -6999,7 +5591,7 @@ var SupervisorLock = class {
|
|
|
6999
5591
|
this.mode = "pid-file";
|
|
7000
5592
|
}
|
|
7001
5593
|
async releaseViaPidFile() {
|
|
7002
|
-
await unlink(this.pidPath).catch(() => void 0);
|
|
5594
|
+
await unlink$1(this.pidPath).catch(() => void 0);
|
|
7003
5595
|
}
|
|
7004
5596
|
/**
|
|
7005
5597
|
* Send SIGTERM to the existing supervisor and wait up to 500ms for it to exit.
|
|
@@ -7032,7 +5624,7 @@ var SupervisorLock = class {
|
|
|
7032
5624
|
|
|
7033
5625
|
//#endregion
|
|
7034
5626
|
//#region src/cli/commands/manifest-read.ts
|
|
7035
|
-
const logger
|
|
5627
|
+
const logger = createLogger("manifest-read");
|
|
7036
5628
|
/**
|
|
7037
5629
|
* Read the active run ID from `.substrate/current-run-id`.
|
|
7038
5630
|
*
|
|
@@ -7064,7 +5656,7 @@ async function readCurrentRunId(dbRoot) {
|
|
|
7064
5656
|
async function resolveRunManifest(dbRoot, runId) {
|
|
7065
5657
|
const resolvedRunId = runId ?? await readCurrentRunId(dbRoot);
|
|
7066
5658
|
if (!resolvedRunId) {
|
|
7067
|
-
logger
|
|
5659
|
+
logger.debug("run manifest not found — falling back to Dolt (no current-run-id)");
|
|
7068
5660
|
return {
|
|
7069
5661
|
manifest: null,
|
|
7070
5662
|
runId: null
|
|
@@ -7079,7 +5671,7 @@ async function resolveRunManifest(dbRoot, runId) {
|
|
|
7079
5671
|
runId: resolvedRunId
|
|
7080
5672
|
};
|
|
7081
5673
|
} catch {
|
|
7082
|
-
logger
|
|
5674
|
+
logger.debug({ runId: resolvedRunId }, "run manifest not found — falling back to Dolt");
|
|
7083
5675
|
return {
|
|
7084
5676
|
manifest: null,
|
|
7085
5677
|
runId: resolvedRunId
|
|
@@ -7088,489 +5680,5 @@ async function resolveRunManifest(dbRoot, runId) {
|
|
|
7088
5680
|
}
|
|
7089
5681
|
|
|
7090
5682
|
//#endregion
|
|
7091
|
-
|
|
7092
|
-
|
|
7093
|
-
/** Default stall threshold in seconds — also used by supervisor default */
|
|
7094
|
-
const DEFAULT_STALL_THRESHOLD_SECONDS = 600;
|
|
7095
|
-
/**
|
|
7096
|
-
* Determine whether a ps output line represents the substrate pipeline orchestrator.
|
|
7097
|
-
* Handles invocation via:
|
|
7098
|
-
* - `substrate run` (globally installed)
|
|
7099
|
-
* - `substrate-ai run`
|
|
7100
|
-
* - `node dist/cli/index.js run` (npm run substrate:dev)
|
|
7101
|
-
* - `npx substrate run`
|
|
7102
|
-
* - any node process whose command contains `run` with `--events` or `--stories`
|
|
7103
|
-
*
|
|
7104
|
-
* When `projectRoot` is provided, additionally checks that the command line
|
|
7105
|
-
* contains that path (via `--project-root` flag or as part of the binary/CWD path).
|
|
7106
|
-
* This ensures multi-project environments match the correct orchestrator.
|
|
7107
|
-
*/
|
|
7108
|
-
function isOrchestratorProcessLine(line, projectRoot) {
|
|
7109
|
-
if (line.includes("grep")) return false;
|
|
7110
|
-
let isOrchestrator = false;
|
|
7111
|
-
if (line.includes("substrate run")) isOrchestrator = true;
|
|
7112
|
-
else if (line.includes("substrate-ai run")) isOrchestrator = true;
|
|
7113
|
-
else if (line.includes("index.js run")) isOrchestrator = true;
|
|
7114
|
-
else if (line.includes("node") && /\srun(\s|$)/.test(line) && (line.includes("substrate") || line.includes("--events") || line.includes("--stories"))) isOrchestrator = true;
|
|
7115
|
-
if (!isOrchestrator) return false;
|
|
7116
|
-
if (projectRoot !== void 0) return line.includes(projectRoot);
|
|
7117
|
-
return true;
|
|
7118
|
-
}
|
|
7119
|
-
function inspectProcessTree(opts) {
|
|
7120
|
-
const { projectRoot, substrateDirPath, execFileSync: execFileSyncOverride, readFileSync: readFileSyncOverride } = opts ?? {};
|
|
7121
|
-
const result = {
|
|
7122
|
-
orchestrator_pid: null,
|
|
7123
|
-
child_pids: [],
|
|
7124
|
-
zombies: []
|
|
7125
|
-
};
|
|
7126
|
-
try {
|
|
7127
|
-
let psOutput;
|
|
7128
|
-
if (execFileSyncOverride !== void 0) psOutput = execFileSyncOverride("ps", ["-eo", "pid,ppid,stat,command"], {
|
|
7129
|
-
encoding: "utf-8",
|
|
7130
|
-
timeout: 5e3
|
|
7131
|
-
});
|
|
7132
|
-
else {
|
|
7133
|
-
const { execFileSync: execFileSync$1 } = __require("node:child_process");
|
|
7134
|
-
psOutput = execFileSync$1("ps", ["-eo", "pid,ppid,stat,command"], {
|
|
7135
|
-
encoding: "utf-8",
|
|
7136
|
-
timeout: 5e3
|
|
7137
|
-
});
|
|
7138
|
-
}
|
|
7139
|
-
const lines = psOutput.split("\n");
|
|
7140
|
-
if (substrateDirPath !== void 0) try {
|
|
7141
|
-
const readFileSyncFn = readFileSyncOverride ?? ((path$3, encoding) => readFileSync(path$3, encoding));
|
|
7142
|
-
const pidContent = readFileSyncFn(join(substrateDirPath, "orchestrator.pid"), "utf-8");
|
|
7143
|
-
const pid = parseInt(pidContent.trim(), 10);
|
|
7144
|
-
if (!isNaN(pid) && pid > 0) {
|
|
7145
|
-
const isAlive = lines.some((line) => {
|
|
7146
|
-
const parts = line.trim().split(/\s+/);
|
|
7147
|
-
if (parts.length < 3) return false;
|
|
7148
|
-
return parseInt(parts[0], 10) === pid && !parts[2].includes("Z");
|
|
7149
|
-
});
|
|
7150
|
-
if (isAlive) result.orchestrator_pid = pid;
|
|
7151
|
-
else result.pid_file_dead = true;
|
|
7152
|
-
}
|
|
7153
|
-
} catch {}
|
|
7154
|
-
if (result.orchestrator_pid === null) {
|
|
7155
|
-
for (const line of lines) if (isOrchestratorProcessLine(line, projectRoot)) {
|
|
7156
|
-
const match = line.trim().match(/^(\d+)/);
|
|
7157
|
-
if (match) {
|
|
7158
|
-
result.orchestrator_pid = parseInt(match[1], 10);
|
|
7159
|
-
break;
|
|
7160
|
-
}
|
|
7161
|
-
}
|
|
7162
|
-
}
|
|
7163
|
-
if (result.orchestrator_pid !== null) for (const line of lines) {
|
|
7164
|
-
const parts = line.trim().split(/\s+/);
|
|
7165
|
-
if (parts.length >= 3) {
|
|
7166
|
-
const pid = parseInt(parts[0], 10);
|
|
7167
|
-
const ppid = parseInt(parts[1], 10);
|
|
7168
|
-
const stat$2 = parts[2];
|
|
7169
|
-
if (ppid === result.orchestrator_pid && pid !== result.orchestrator_pid) {
|
|
7170
|
-
result.child_pids.push(pid);
|
|
7171
|
-
if (stat$2.includes("Z")) result.zombies.push(pid);
|
|
7172
|
-
}
|
|
7173
|
-
}
|
|
7174
|
-
}
|
|
7175
|
-
} catch {}
|
|
7176
|
-
return result;
|
|
7177
|
-
}
|
|
7178
|
-
/**
|
|
7179
|
-
* Collect all descendant PIDs of the given root PIDs by walking the process
|
|
7180
|
-
* tree recursively. This ensures that grandchildren of the orchestrator
|
|
7181
|
-
* (e.g. node subprocesses spawned by `claude -p`) are also killed during
|
|
7182
|
-
* stall recovery, leaving no orphan processes.
|
|
7183
|
-
*
|
|
7184
|
-
* Returns only the descendants — the root PIDs themselves are NOT included.
|
|
7185
|
-
*/
|
|
7186
|
-
function getAllDescendantPids(rootPids, execFileSyncOverride) {
|
|
7187
|
-
if (rootPids.length === 0) return [];
|
|
7188
|
-
try {
|
|
7189
|
-
let psOutput;
|
|
7190
|
-
if (execFileSyncOverride !== void 0) psOutput = execFileSyncOverride("ps", ["-eo", "pid,ppid"], {
|
|
7191
|
-
encoding: "utf-8",
|
|
7192
|
-
timeout: 5e3
|
|
7193
|
-
});
|
|
7194
|
-
else {
|
|
7195
|
-
const { execFileSync: execFileSync$1 } = __require("node:child_process");
|
|
7196
|
-
psOutput = execFileSync$1("ps", ["-eo", "pid,ppid"], {
|
|
7197
|
-
encoding: "utf-8",
|
|
7198
|
-
timeout: 5e3
|
|
7199
|
-
});
|
|
7200
|
-
}
|
|
7201
|
-
const childrenOf = new Map();
|
|
7202
|
-
for (const line of psOutput.split("\n")) {
|
|
7203
|
-
const parts = line.trim().split(/\s+/);
|
|
7204
|
-
if (parts.length >= 2) {
|
|
7205
|
-
const pid = parseInt(parts[0], 10);
|
|
7206
|
-
const ppid = parseInt(parts[1], 10);
|
|
7207
|
-
if (!isNaN(pid) && !isNaN(ppid) && pid > 0) {
|
|
7208
|
-
if (!childrenOf.has(ppid)) childrenOf.set(ppid, []);
|
|
7209
|
-
childrenOf.get(ppid).push(pid);
|
|
7210
|
-
}
|
|
7211
|
-
}
|
|
7212
|
-
}
|
|
7213
|
-
const descendants = [];
|
|
7214
|
-
const seen = new Set(rootPids);
|
|
7215
|
-
const queue = [...rootPids];
|
|
7216
|
-
while (queue.length > 0) {
|
|
7217
|
-
const current = queue.shift();
|
|
7218
|
-
const children = childrenOf.get(current) ?? [];
|
|
7219
|
-
for (const child of children) if (!seen.has(child)) {
|
|
7220
|
-
seen.add(child);
|
|
7221
|
-
descendants.push(child);
|
|
7222
|
-
queue.push(child);
|
|
7223
|
-
}
|
|
7224
|
-
}
|
|
7225
|
-
return descendants;
|
|
7226
|
-
} catch {
|
|
7227
|
-
return [];
|
|
7228
|
-
}
|
|
7229
|
-
}
|
|
7230
|
-
/**
|
|
7231
|
-
* Derive health story counts from manifest `per_story_state`.
|
|
7232
|
-
* Maps manifest status strings to health output buckets.
|
|
7233
|
-
*/
|
|
7234
|
-
function buildHealthStoryCountsFromManifest(perStoryState) {
|
|
7235
|
-
const counts = {
|
|
7236
|
-
active: 0,
|
|
7237
|
-
completed: 0,
|
|
7238
|
-
escalated: 0,
|
|
7239
|
-
pending: 0,
|
|
7240
|
-
failed: 0
|
|
7241
|
-
};
|
|
7242
|
-
for (const entry of Object.values(perStoryState)) switch (entry.status) {
|
|
7243
|
-
case "complete":
|
|
7244
|
-
counts.completed++;
|
|
7245
|
-
break;
|
|
7246
|
-
case "escalated":
|
|
7247
|
-
counts.escalated++;
|
|
7248
|
-
break;
|
|
7249
|
-
case "failed":
|
|
7250
|
-
case "verification-failed":
|
|
7251
|
-
counts.failed++;
|
|
7252
|
-
break;
|
|
7253
|
-
case "pending":
|
|
7254
|
-
case "gated":
|
|
7255
|
-
counts.pending++;
|
|
7256
|
-
break;
|
|
7257
|
-
case "dispatched":
|
|
7258
|
-
case "in-review":
|
|
7259
|
-
case "recovered":
|
|
7260
|
-
default:
|
|
7261
|
-
counts.active++;
|
|
7262
|
-
break;
|
|
7263
|
-
}
|
|
7264
|
-
return counts;
|
|
7265
|
-
}
|
|
7266
|
-
/**
|
|
7267
|
-
* Fetch pipeline health data as a structured object without any stdout side-effects.
|
|
7268
|
-
* Used by runSupervisorAction to poll health without formatting overhead.
|
|
7269
|
-
*
|
|
7270
|
-
* Returns a NO_PIPELINE_RUNNING health object for all graceful "no data" cases
|
|
7271
|
-
* (missing DB, missing run, terminal run status). Throws only on unexpected errors.
|
|
7272
|
-
*/
|
|
7273
|
-
async function getAutoHealthData(options) {
|
|
7274
|
-
const { runId, projectRoot, stateStore, stateStoreConfig } = options;
|
|
7275
|
-
const dbRoot = await resolveMainRepoRoot(projectRoot);
|
|
7276
|
-
const dbPath = join(dbRoot, ".substrate", "substrate.db");
|
|
7277
|
-
let doltStateInfo;
|
|
7278
|
-
if (stateStoreConfig?.backend === "dolt" && stateStore) {
|
|
7279
|
-
const repoPath = stateStoreConfig.basePath ?? projectRoot;
|
|
7280
|
-
const doltDirPath = join(repoPath, ".dolt");
|
|
7281
|
-
const initialized = existsSync(doltDirPath);
|
|
7282
|
-
let responsive = false;
|
|
7283
|
-
let version;
|
|
7284
|
-
let branches;
|
|
7285
|
-
let currentBranch;
|
|
7286
|
-
try {
|
|
7287
|
-
await stateStore.getHistory(1);
|
|
7288
|
-
responsive = true;
|
|
7289
|
-
try {
|
|
7290
|
-
const { execFile: ef } = await import("node:child_process");
|
|
7291
|
-
const { promisify: p } = await import("node:util");
|
|
7292
|
-
const execFileAsync = p(ef);
|
|
7293
|
-
const { stdout } = await execFileAsync("dolt", ["version"]);
|
|
7294
|
-
const match = stdout.match(/dolt version (\S+)/);
|
|
7295
|
-
if (match) version = match[1];
|
|
7296
|
-
} catch {}
|
|
7297
|
-
try {
|
|
7298
|
-
const { execFile: ef } = await import("node:child_process");
|
|
7299
|
-
const { promisify: p } = await import("node:util");
|
|
7300
|
-
const execFileAsync = p(ef);
|
|
7301
|
-
const { stdout } = await execFileAsync("dolt", ["branch", "--list"], { cwd: repoPath });
|
|
7302
|
-
const lines = stdout.split("\n").filter((l) => l.trim().length > 0);
|
|
7303
|
-
branches = lines.map((l) => {
|
|
7304
|
-
const trimmed = l.trim();
|
|
7305
|
-
if (trimmed.startsWith("* ")) {
|
|
7306
|
-
currentBranch = trimmed.slice(2).trim();
|
|
7307
|
-
return currentBranch;
|
|
7308
|
-
}
|
|
7309
|
-
return trimmed;
|
|
7310
|
-
});
|
|
7311
|
-
} catch {}
|
|
7312
|
-
} catch {
|
|
7313
|
-
responsive = false;
|
|
7314
|
-
}
|
|
7315
|
-
doltStateInfo = {
|
|
7316
|
-
initialized,
|
|
7317
|
-
responsive,
|
|
7318
|
-
...version !== void 0 ? { version } : {},
|
|
7319
|
-
...branches !== void 0 ? { branches } : {},
|
|
7320
|
-
...currentBranch !== void 0 ? { current_branch: currentBranch } : {}
|
|
7321
|
-
};
|
|
7322
|
-
}
|
|
7323
|
-
const NO_PIPELINE = {
|
|
7324
|
-
verdict: "NO_PIPELINE_RUNNING",
|
|
7325
|
-
run_id: null,
|
|
7326
|
-
status: null,
|
|
7327
|
-
current_phase: null,
|
|
7328
|
-
staleness_seconds: 0,
|
|
7329
|
-
last_activity: "",
|
|
7330
|
-
process: {
|
|
7331
|
-
orchestrator_pid: null,
|
|
7332
|
-
child_pids: [],
|
|
7333
|
-
zombies: []
|
|
7334
|
-
},
|
|
7335
|
-
stories: {
|
|
7336
|
-
active: 0,
|
|
7337
|
-
completed: 0,
|
|
7338
|
-
escalated: 0,
|
|
7339
|
-
details: {}
|
|
7340
|
-
},
|
|
7341
|
-
...doltStateInfo !== void 0 ? { dolt_state: doltStateInfo } : {}
|
|
7342
|
-
};
|
|
7343
|
-
const doltDir = join(dbRoot, ".substrate", "state", ".dolt");
|
|
7344
|
-
if (!existsSync(dbPath) && !existsSync(doltDir)) return NO_PIPELINE;
|
|
7345
|
-
const adapter = createDatabaseAdapter$1({
|
|
7346
|
-
backend: "auto",
|
|
7347
|
-
basePath: dbRoot
|
|
7348
|
-
});
|
|
7349
|
-
try {
|
|
7350
|
-
await initSchema(adapter);
|
|
7351
|
-
let run;
|
|
7352
|
-
if (runId !== void 0) run = await getPipelineRunById(adapter, runId);
|
|
7353
|
-
else {
|
|
7354
|
-
let currentRunId;
|
|
7355
|
-
try {
|
|
7356
|
-
const currentRunIdPath = join(dbRoot, ".substrate", "current-run-id");
|
|
7357
|
-
const content = readFileSync(currentRunIdPath, "utf-8").trim();
|
|
7358
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7359
|
-
if (UUID_RE.test(content)) currentRunId = content;
|
|
7360
|
-
} catch {}
|
|
7361
|
-
if (currentRunId !== void 0) run = await getPipelineRunById(adapter, currentRunId);
|
|
7362
|
-
if (run === void 0) run = await getLatestRun(adapter);
|
|
7363
|
-
}
|
|
7364
|
-
if (run === void 0) {
|
|
7365
|
-
const substrateDirPath$1 = join(dbRoot, ".substrate");
|
|
7366
|
-
const fallbackProcessInfo = inspectProcessTree({
|
|
7367
|
-
projectRoot: dbRoot,
|
|
7368
|
-
substrateDirPath: substrateDirPath$1
|
|
7369
|
-
});
|
|
7370
|
-
if (fallbackProcessInfo.orchestrator_pid !== null) return {
|
|
7371
|
-
verdict: "HEALTHY",
|
|
7372
|
-
run_id: null,
|
|
7373
|
-
status: "running",
|
|
7374
|
-
current_phase: "implementation",
|
|
7375
|
-
staleness_seconds: 0,
|
|
7376
|
-
last_activity: new Date().toISOString(),
|
|
7377
|
-
process: fallbackProcessInfo,
|
|
7378
|
-
stories: {
|
|
7379
|
-
active: 0,
|
|
7380
|
-
completed: 0,
|
|
7381
|
-
escalated: 0,
|
|
7382
|
-
details: {}
|
|
7383
|
-
},
|
|
7384
|
-
...doltStateInfo !== void 0 ? { dolt_state: doltStateInfo } : {}
|
|
7385
|
-
};
|
|
7386
|
-
return NO_PIPELINE;
|
|
7387
|
-
}
|
|
7388
|
-
const updatedAt = parseDbTimestampAsUtc(run.updated_at ?? "");
|
|
7389
|
-
const stalenessSeconds = Math.round((Date.now() - updatedAt.getTime()) / 1e3);
|
|
7390
|
-
let storyDetails = {};
|
|
7391
|
-
let active = 0;
|
|
7392
|
-
let completed = 0;
|
|
7393
|
-
let escalated = 0;
|
|
7394
|
-
let pending = 0;
|
|
7395
|
-
try {
|
|
7396
|
-
if (run.token_usage_json) {
|
|
7397
|
-
const state = JSON.parse(run.token_usage_json);
|
|
7398
|
-
if (state.stories) for (const [key, s] of Object.entries(state.stories)) {
|
|
7399
|
-
storyDetails[key] = {
|
|
7400
|
-
phase: s.phase,
|
|
7401
|
-
review_cycles: s.reviewCycles
|
|
7402
|
-
};
|
|
7403
|
-
if (s.phase === "COMPLETE") completed++;
|
|
7404
|
-
else if (s.phase === "ESCALATED") escalated++;
|
|
7405
|
-
else if (s.phase === "PENDING") pending++;
|
|
7406
|
-
else active++;
|
|
7407
|
-
}
|
|
7408
|
-
}
|
|
7409
|
-
} catch {}
|
|
7410
|
-
let manifestSupervisor;
|
|
7411
|
-
let manifestStoryCounts;
|
|
7412
|
-
try {
|
|
7413
|
-
const { manifest: resolvedManifest } = await resolveRunManifest(dbRoot, run.id);
|
|
7414
|
-
if (resolvedManifest !== null) {
|
|
7415
|
-
const manifestData = await resolvedManifest.read();
|
|
7416
|
-
manifestSupervisor = {
|
|
7417
|
-
pid: manifestData.supervisor_pid,
|
|
7418
|
-
session_id: manifestData.supervisor_session_id
|
|
7419
|
-
};
|
|
7420
|
-
manifestStoryCounts = buildHealthStoryCountsFromManifest(manifestData.per_story_state);
|
|
7421
|
-
logger.debug({ runId: run.id }, "health: story counts and supervisor read from manifest");
|
|
7422
|
-
}
|
|
7423
|
-
} catch {
|
|
7424
|
-
logger.debug({ runId: run.id }, "health: manifest read failed — using token_usage_json counts");
|
|
7425
|
-
}
|
|
7426
|
-
const finalActive = manifestStoryCounts?.active ?? active;
|
|
7427
|
-
const finalCompleted = manifestStoryCounts?.completed ?? completed;
|
|
7428
|
-
const finalEscalated = manifestStoryCounts?.escalated ?? escalated;
|
|
7429
|
-
const finalPending = manifestStoryCounts?.pending ?? pending;
|
|
7430
|
-
const finalFailed = manifestStoryCounts?.failed;
|
|
7431
|
-
const substrateDirPath = join(dbRoot, ".substrate");
|
|
7432
|
-
const processInfo = options._processInfoOverride ?? inspectProcessTree({
|
|
7433
|
-
projectRoot,
|
|
7434
|
-
substrateDirPath
|
|
7435
|
-
});
|
|
7436
|
-
let verdict = "NO_PIPELINE_RUNNING";
|
|
7437
|
-
if (run.status === "running") if (processInfo.orchestrator_pid !== null) verdict = "HEALTHY";
|
|
7438
|
-
else if (processInfo.pid_file_dead === true) verdict = "STALLED";
|
|
7439
|
-
else if (processInfo.zombies.length > 0) verdict = "STALLED";
|
|
7440
|
-
else if (stalenessSeconds > DEFAULT_STALL_THRESHOLD_SECONDS) verdict = "STALLED";
|
|
7441
|
-
else if (finalActive > 0) verdict = "STALLED";
|
|
7442
|
-
else verdict = "HEALTHY";
|
|
7443
|
-
else if (run.status === "completed" || run.status === "failed" || run.status === "stopped") verdict = "NO_PIPELINE_RUNNING";
|
|
7444
|
-
const warnings = [];
|
|
7445
|
-
if (doltStateInfo !== void 0 && doltStateInfo.responsive === false) warnings.push("Dolt not connected — decision store queries may fail, story context will be degraded");
|
|
7446
|
-
if (finalEscalated > 0) warnings.push(`${finalEscalated} story(ies) escalated — operator intervention may be needed`);
|
|
7447
|
-
const healthOutput = {
|
|
7448
|
-
verdict,
|
|
7449
|
-
run_id: run.id,
|
|
7450
|
-
status: run.status,
|
|
7451
|
-
current_phase: run.current_phase ?? null,
|
|
7452
|
-
staleness_seconds: stalenessSeconds,
|
|
7453
|
-
last_activity: run.updated_at ?? "",
|
|
7454
|
-
process: processInfo,
|
|
7455
|
-
stories: {
|
|
7456
|
-
active: finalActive,
|
|
7457
|
-
completed: finalCompleted,
|
|
7458
|
-
escalated: finalEscalated,
|
|
7459
|
-
pending: finalPending,
|
|
7460
|
-
...finalFailed !== void 0 ? { failed: finalFailed } : {},
|
|
7461
|
-
details: storyDetails
|
|
7462
|
-
},
|
|
7463
|
-
...manifestSupervisor !== void 0 ? { manifest_supervisor: manifestSupervisor } : {},
|
|
7464
|
-
...doltStateInfo !== void 0 ? { dolt_state: doltStateInfo } : {},
|
|
7465
|
-
...warnings.length > 0 ? { warnings } : {}
|
|
7466
|
-
};
|
|
7467
|
-
return healthOutput;
|
|
7468
|
-
} finally {
|
|
7469
|
-
try {
|
|
7470
|
-
await adapter.close();
|
|
7471
|
-
} catch {}
|
|
7472
|
-
}
|
|
7473
|
-
}
|
|
7474
|
-
async function runHealthAction(options) {
|
|
7475
|
-
const { outputFormat } = options;
|
|
7476
|
-
try {
|
|
7477
|
-
const health = await getAutoHealthData(options);
|
|
7478
|
-
if (outputFormat === "json") process.stdout.write(formatOutput(health, "json", true) + "\n");
|
|
7479
|
-
else {
|
|
7480
|
-
const verdictLabel = health.verdict === "HEALTHY" ? "HEALTHY" : health.verdict === "STALLED" ? "STALLED" : "NO PIPELINE RUNNING";
|
|
7481
|
-
process.stdout.write(`\nPipeline Health: ${verdictLabel}\n`);
|
|
7482
|
-
if (health.run_id !== null) {
|
|
7483
|
-
process.stdout.write(` Run: ${health.run_id}\n`);
|
|
7484
|
-
process.stdout.write(` Status: ${health.status}\n`);
|
|
7485
|
-
process.stdout.write(` Phase: ${health.current_phase ?? "N/A"}\n`);
|
|
7486
|
-
process.stdout.write(` Last Active: ${health.last_activity} (${health.staleness_seconds}s ago)\n`);
|
|
7487
|
-
const processInfo = health.process;
|
|
7488
|
-
if (processInfo.orchestrator_pid !== null) {
|
|
7489
|
-
process.stdout.write(` Orchestrator: PID ${processInfo.orchestrator_pid}\n`);
|
|
7490
|
-
process.stdout.write(` Children: ${processInfo.child_pids.length} active`);
|
|
7491
|
-
if (processInfo.zombies.length > 0) process.stdout.write(` (${processInfo.zombies.length} ZOMBIE)`);
|
|
7492
|
-
process.stdout.write("\n");
|
|
7493
|
-
} else process.stdout.write(" Orchestrator: not running\n");
|
|
7494
|
-
const storyDetails = health.stories.details;
|
|
7495
|
-
if (Object.keys(storyDetails).length > 0) {
|
|
7496
|
-
process.stdout.write("\n Stories:\n");
|
|
7497
|
-
for (const [key, s] of Object.entries(storyDetails)) process.stdout.write(` ${key}: ${s.phase} (${s.review_cycles} review cycles)\n`);
|
|
7498
|
-
process.stdout.write(`\n Summary: ${health.stories.active} active, ${health.stories.completed} completed, ${health.stories.escalated} escalated\n`);
|
|
7499
|
-
}
|
|
7500
|
-
}
|
|
7501
|
-
if (health.verdict === "STALLED") {
|
|
7502
|
-
process.stdout.write("\n Recommended Actions:\n");
|
|
7503
|
-
if (health.process.orchestrator_pid !== null) process.stdout.write(` 1. Kill stalled orchestrator: kill ${health.process.orchestrator_pid}\n`);
|
|
7504
|
-
if (health.process.zombies.length > 0) process.stdout.write(` ${health.process.orchestrator_pid !== null ? "2" : "1"}. Kill zombie processes: kill ${health.process.zombies.join(" ")}\n`);
|
|
7505
|
-
process.stdout.write(` ${health.process.orchestrator_pid !== null ? "3" : "2"}. Resume the run: substrate resume\n`);
|
|
7506
|
-
process.stdout.write(` ${health.process.orchestrator_pid !== null ? "4" : "3"}. Or start fresh: substrate run --events --stories <keys>\n`);
|
|
7507
|
-
} else if (health.verdict === "NO_PIPELINE_RUNNING" && health.stories.escalated > 0) {
|
|
7508
|
-
process.stdout.write("\n Recommended Actions:\n");
|
|
7509
|
-
process.stdout.write(" 1. Retry escalated stories: substrate retry-escalated\n");
|
|
7510
|
-
process.stdout.write(" 2. Or start a new run: substrate run --events\n");
|
|
7511
|
-
}
|
|
7512
|
-
if (health.warnings !== void 0 && health.warnings.length > 0) {
|
|
7513
|
-
process.stdout.write("\n Warnings:\n");
|
|
7514
|
-
for (const w of health.warnings) process.stdout.write(` ⚠ ${w}\n`);
|
|
7515
|
-
}
|
|
7516
|
-
if (health.dolt_state !== void 0) {
|
|
7517
|
-
const ds = health.dolt_state;
|
|
7518
|
-
const initStr = ds.initialized ? "yes" : "no";
|
|
7519
|
-
const respStr = ds.responsive ? "yes" : "no";
|
|
7520
|
-
const verStr = ds.version !== void 0 ? ` (v${ds.version})` : "";
|
|
7521
|
-
process.stdout.write(`\n Dolt State: initialized=${initStr} responsive=${respStr}${verStr}\n`);
|
|
7522
|
-
}
|
|
7523
|
-
}
|
|
7524
|
-
return 0;
|
|
7525
|
-
} catch (err) {
|
|
7526
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
7527
|
-
if (outputFormat === "json") process.stdout.write(formatOutput(null, "json", false, msg) + "\n");
|
|
7528
|
-
else process.stderr.write(`Error: ${msg}\n`);
|
|
7529
|
-
logger.error({ err }, "health action failed");
|
|
7530
|
-
return 1;
|
|
7531
|
-
}
|
|
7532
|
-
}
|
|
7533
|
-
function registerHealthCommand(program, _version = "0.0.0", projectRoot = process.cwd()) {
|
|
7534
|
-
program.command("health").description("Check pipeline health: process status, stall detection, and verdict").option("--run-id <id>", "Pipeline run ID to query (defaults to latest)").option("--project-root <path>", "Project root directory", projectRoot).option("--output-format <format>", "Output format: human (default) or json", "human").action(async (opts) => {
|
|
7535
|
-
const outputFormat = opts.outputFormat === "json" ? "json" : "human";
|
|
7536
|
-
const root = opts.projectRoot;
|
|
7537
|
-
let stateStore;
|
|
7538
|
-
let stateStoreConfig;
|
|
7539
|
-
const doltStatePath = join(root, ".substrate", "state", ".dolt");
|
|
7540
|
-
if (existsSync(doltStatePath)) {
|
|
7541
|
-
const basePath = join(root, ".substrate", "state");
|
|
7542
|
-
stateStoreConfig = {
|
|
7543
|
-
backend: "dolt",
|
|
7544
|
-
basePath
|
|
7545
|
-
};
|
|
7546
|
-
try {
|
|
7547
|
-
stateStore = createStateStore({
|
|
7548
|
-
backend: "dolt",
|
|
7549
|
-
basePath
|
|
7550
|
-
});
|
|
7551
|
-
await stateStore.initialize();
|
|
7552
|
-
} catch {
|
|
7553
|
-
stateStore = void 0;
|
|
7554
|
-
stateStoreConfig = void 0;
|
|
7555
|
-
}
|
|
7556
|
-
}
|
|
7557
|
-
try {
|
|
7558
|
-
const exitCode = await runHealthAction({
|
|
7559
|
-
outputFormat,
|
|
7560
|
-
runId: opts.runId,
|
|
7561
|
-
projectRoot: root,
|
|
7562
|
-
stateStore,
|
|
7563
|
-
stateStoreConfig
|
|
7564
|
-
});
|
|
7565
|
-
process.exitCode = exitCode;
|
|
7566
|
-
} finally {
|
|
7567
|
-
try {
|
|
7568
|
-
await stateStore?.close();
|
|
7569
|
-
} catch {}
|
|
7570
|
-
}
|
|
7571
|
-
});
|
|
7572
|
-
}
|
|
7573
|
-
|
|
7574
|
-
//#endregion
|
|
7575
|
-
export { BMAD_BASELINE_TOKENS_FULL, DEFAULT_STALL_THRESHOLD_SECONDS, DoltMergeConflict, FileStateStore, FindingsInjector, RunManifest, RuntimeProbeListSchema, STOP_AFTER_VALID_PHASES, STORY_KEY_PATTERN$1 as STORY_KEY_PATTERN, SUBSTRATE_OWNED_SETTINGS_KEYS, SupervisorLock, VALID_PHASES, WorkGraphRepository, ZERO_FINDINGS_BY_AUTHOR, ZERO_FINDING_COUNTS, ZERO_PROBE_AUTHOR_METRICS, __commonJS, __require, __toESM, aggregateProbeAuthorMetrics, applyConfigToGraph, buildPipelineStatusOutput, createDatabaseAdapter$1 as createDatabaseAdapter, createDefaultVerificationPipeline, createGraphOrchestrator, createSdlcCodeReviewHandler, createSdlcCreateStoryHandler, createSdlcDevStoryHandler, createSdlcPhaseHandler, createStateStore, detectCycles, detectsEventDrivenAC, detectsStateIntegratingAC, extractTargetFilesFromStoryContent, findPackageRoot, formatOutput, formatPipelineStatusHuman, formatPipelineSummary, formatTokenTelemetry, getAllDescendantPids, getAutoHealthData, getSubstrateDefaultSettings, inspectProcessTree, isOrchestratorProcessLine, parseDbTimestampAsUtc, parseRuntimeProbes, readCurrentRunId, registerHealthCommand, renderFindings, resolveBmadMethodSrcPath, resolveBmadMethodVersion, resolveGraphPath, resolveMainRepoRoot, resolveRunManifest, rollupFindingCounts, rollupFindingsByAuthor, rollupProbeAuthorByClass, rollupProbeAuthorMetrics, runHealthAction, runStaleVerificationRecovery, validateStoryKey };
|
|
7576
|
-
//# sourceMappingURL=health-DudlnqXd.js.map
|
|
5683
|
+
export { FindingsInjector, RunManifest, RuntimeProbeListSchema, SupervisorLock, ZERO_FINDINGS_BY_AUTHOR, ZERO_FINDING_COUNTS, ZERO_PROBE_AUTHOR_METRICS, aggregateProbeAuthorMetrics, applyConfigToGraph, createDefaultVerificationPipeline, createGraphOrchestrator, createSdlcCodeReviewHandler, createSdlcCreateStoryHandler, createSdlcDevStoryHandler, createSdlcPhaseHandler, detectsEventDrivenAC, detectsStateIntegratingAC, extractTargetFilesFromStoryContent, parseRuntimeProbes, readCurrentRunId, renderFindings, resolveGraphPath, resolveMainRepoRoot, resolveRunManifest, rollupFindingCounts, rollupFindingsByAuthor, rollupProbeAuthorByClass, rollupProbeAuthorMetrics, runStaleVerificationRecovery };
|
|
5684
|
+
//# sourceMappingURL=manifest-read-DDkXC3L_.js.map
|