@deksden-com/dd-flow-cli 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +74 -5
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +103 -15
- package/dist/cli/run-cli.js +307 -27
- package/dist/schemas/compatibility.schema.json +81 -2
- package/dist/schemas/engine-manifest.schema.json +61 -0
- package/dist/schemas/flow-guidance.schema.json +17 -0
- package/dist/schemas/global-dashboard-data.schema.json +60 -2
- package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
- package/dist/schemas/project-dashboard-data.schema.json +26 -2
- package/dist/schemas/project-summary.schema.json +73 -0
- package/dist/schemas/protocol-dashboard-data.schema.json +25 -2
- package/dist/schemas/status-report.schema.json +4 -2
- package/dist/services/cleanup.js +31 -0
- package/dist/services/cli-operation-classifier.js +104 -0
- package/dist/services/compatibility-preflight.js +124 -0
- package/dist/services/config.js +6 -0
- package/dist/services/dashboard-targets.js +95 -0
- package/dist/services/dashboard.js +376 -59
- package/dist/services/engines.js +532 -0
- package/dist/services/flow-guidance.js +8 -1
- package/dist/services/hooks.js +1 -1
- package/dist/services/lanes.js +333 -1
- package/dist/services/merge-queue.js +97 -15
- package/dist/services/merge-worker.js +36 -2
- package/dist/services/migrations.js +231 -0
- package/dist/services/project-summary.js +122 -0
- package/dist/services/projects.js +41 -6
- package/dist/services/protocol-lifecycle.js +144 -0
- package/dist/services/protocols.js +29 -7
- package/dist/services/sessions.js +21 -4
- package/dist/services/status.js +10 -0
- package/dist/services/version-status.js +39 -15
- package/dist/storage/database.js +25 -0
- package/dist/storage/paths.js +12 -0
- package/package.json +3 -2
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppError } from "../shared/errors.js";
|
|
4
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
5
|
+
import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
|
|
6
|
+
export function planMigration(context, input) {
|
|
7
|
+
const rootResolution = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
|
|
8
|
+
if (!rootResolution.root) {
|
|
9
|
+
throw new AppError("not_found", `Project root does not exist: ${input.projectRoot}`, 1);
|
|
10
|
+
}
|
|
11
|
+
const project = requireProjectByRoot(context, rootResolution.root);
|
|
12
|
+
const versionStatus = getProjectVersionStatus({
|
|
13
|
+
projectRoot: rootResolution.root,
|
|
14
|
+
rootSource: rootResolution.root_source
|
|
15
|
+
});
|
|
16
|
+
const sourceVersion = input.sourceVersion ?? versionStatus?.memory_bank.version ?? "unknown";
|
|
17
|
+
const targetVersion = input.targetVersion ?? versionStatus?.memory_bank.version ?? "unknown";
|
|
18
|
+
const chain = adjacentMigrationChain(sourceVersion, targetVersion);
|
|
19
|
+
const activeState = activeStateSummary(context, project.id);
|
|
20
|
+
const backup = backupEvidence(input);
|
|
21
|
+
const blockers = [
|
|
22
|
+
...(chain.status === "unsupported" ? [`unsupported migration path: ${chain.reason}`] : []),
|
|
23
|
+
...(backup.status === "missing" ? ["backup evidence is required before runtime/home migration"] : []),
|
|
24
|
+
...(input.allowActive ? [] : activeStateBlockers(activeState))
|
|
25
|
+
];
|
|
26
|
+
const status = blockers.length > 0 ? "blocked" : chain.steps.length === 0 ? "noop" : "ready";
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
schema_id: "dd-flow/mb-upgrade-migration-report@1",
|
|
30
|
+
generated_at: context.now(),
|
|
31
|
+
project: {
|
|
32
|
+
id: project.id,
|
|
33
|
+
root: project.root,
|
|
34
|
+
memory_bank_version: versionStatus?.memory_bank.version ?? null
|
|
35
|
+
},
|
|
36
|
+
run: {
|
|
37
|
+
id: input.runId ?? null,
|
|
38
|
+
flow_kind: "mb-upgrade"
|
|
39
|
+
},
|
|
40
|
+
migration: {
|
|
41
|
+
mode: "mb-upgrade-only",
|
|
42
|
+
status,
|
|
43
|
+
source_memory_bank_version: sourceVersion,
|
|
44
|
+
target_memory_bank_version: targetVersion,
|
|
45
|
+
adjacent_only: true,
|
|
46
|
+
chain: chain.steps,
|
|
47
|
+
blocked_reasons: blockers
|
|
48
|
+
},
|
|
49
|
+
engine: {
|
|
50
|
+
router_version: null,
|
|
51
|
+
selected_engine_version: null,
|
|
52
|
+
source_engine_available: "unknown",
|
|
53
|
+
target_engine_available: "unknown"
|
|
54
|
+
},
|
|
55
|
+
backup,
|
|
56
|
+
active_state: activeState,
|
|
57
|
+
primary_data: [
|
|
58
|
+
"project registry records",
|
|
59
|
+
"protocol runtime",
|
|
60
|
+
"run indexes",
|
|
61
|
+
"queues/lanes/sessions/locks when their contracts change",
|
|
62
|
+
"flow-pack manifests and compatibility metadata"
|
|
63
|
+
],
|
|
64
|
+
derived_artifacts: [
|
|
65
|
+
"project dashboard artifacts",
|
|
66
|
+
"global dashboard data/render outputs",
|
|
67
|
+
"project summary outputs after PRT-059",
|
|
68
|
+
"cached indexes that can be rebuilt"
|
|
69
|
+
],
|
|
70
|
+
verification: {
|
|
71
|
+
post_upgrade_required: true,
|
|
72
|
+
commands: [
|
|
73
|
+
"dd-flow migration verify --file <report> --json",
|
|
74
|
+
"dd-flow dashboard refresh --project <project>",
|
|
75
|
+
"dd-flow status --project-root <project-root> --json"
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function verifyMigrationReport(_context, input) {
|
|
81
|
+
const filePath = path.resolve(input.file);
|
|
82
|
+
const report = readJsonObject(filePath);
|
|
83
|
+
const schemaId = stringValue(report.schema_id);
|
|
84
|
+
if (schemaId !== "dd-flow/mb-upgrade-migration-report@1") {
|
|
85
|
+
throw new AppError("validation", "Migration report schema_id is not dd-flow/mb-upgrade-migration-report@1", 2, {
|
|
86
|
+
file: filePath,
|
|
87
|
+
schema_id: schemaId
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const migration = recordValue(report.migration);
|
|
91
|
+
const backup = recordValue(report.backup);
|
|
92
|
+
const chain = Array.isArray(migration?.chain) ? migration.chain : [];
|
|
93
|
+
const backupStatus = stringValue(backup?.status);
|
|
94
|
+
const blockedReasons = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
|
|
95
|
+
const errors = [];
|
|
96
|
+
if (!migration)
|
|
97
|
+
errors.push("migration block is required");
|
|
98
|
+
if (!backup)
|
|
99
|
+
errors.push("backup block is required");
|
|
100
|
+
if (backupStatus !== "present" && backupStatus !== "planned")
|
|
101
|
+
errors.push("backup.status must be present or planned");
|
|
102
|
+
if (migration?.adjacent_only !== true)
|
|
103
|
+
errors.push("migration.adjacent_only must be true");
|
|
104
|
+
if (chain.some((step) => !isAdjacentStep(step)))
|
|
105
|
+
errors.push("all migration.chain entries must be adjacent steps with from/to/status");
|
|
106
|
+
if (stringValue(migration?.status) === "ready" && blockedReasons.length > 0) {
|
|
107
|
+
errors.push("ready migration report must not contain blocked reasons");
|
|
108
|
+
}
|
|
109
|
+
if (errors.length > 0) {
|
|
110
|
+
throw new AppError("migration_report_invalid", "Migration report is not acceptable", 2, { file: filePath, errors });
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
file: filePath,
|
|
115
|
+
schema_id: schemaId,
|
|
116
|
+
verdict: blockedReasons.length > 0 ? "blocked_report_valid" : "accepted",
|
|
117
|
+
checks: {
|
|
118
|
+
backup: backupStatus,
|
|
119
|
+
adjacent_chain_steps: chain.length,
|
|
120
|
+
blocked_reasons: blockedReasons.length
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function backupEvidence(input) {
|
|
125
|
+
if (!input.backupPath) {
|
|
126
|
+
return {
|
|
127
|
+
status: "missing",
|
|
128
|
+
path: null,
|
|
129
|
+
created_at: null,
|
|
130
|
+
rollback_route: "required before applying runtime/home migration"
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
status: fs.existsSync(path.resolve(input.backupPath)) ? "present" : "planned",
|
|
135
|
+
path: path.resolve(input.backupPath),
|
|
136
|
+
created_at: input.backupCreatedAt ?? null,
|
|
137
|
+
rollback_route: "restore backup before retrying migration"
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function adjacentMigrationChain(source, target) {
|
|
141
|
+
if (source === target)
|
|
142
|
+
return { status: "ok", steps: [] };
|
|
143
|
+
const sourceParts = parseSemver(source);
|
|
144
|
+
const targetParts = parseSemver(target);
|
|
145
|
+
if (!sourceParts || !targetParts) {
|
|
146
|
+
return { status: "unsupported", reason: "source or target version is not semver", steps: [] };
|
|
147
|
+
}
|
|
148
|
+
if (sourceParts.major !== targetParts.major) {
|
|
149
|
+
return { status: "unsupported", reason: "major version migration requires explicit future migration units", steps: [] };
|
|
150
|
+
}
|
|
151
|
+
if (targetParts.minor < sourceParts.minor || (targetParts.minor === sourceParts.minor && targetParts.patch < sourceParts.patch)) {
|
|
152
|
+
return { status: "unsupported", reason: "downgrade migrations are not supported", steps: [] };
|
|
153
|
+
}
|
|
154
|
+
const steps = [];
|
|
155
|
+
let current = { ...sourceParts };
|
|
156
|
+
while (compareParts(current, targetParts) < 0) {
|
|
157
|
+
const next = nextAdjacent(current, targetParts);
|
|
158
|
+
steps.push({
|
|
159
|
+
id: `${formatParts(current)}-to-${formatParts(next)}`,
|
|
160
|
+
from: formatParts(current),
|
|
161
|
+
to: formatParts(next),
|
|
162
|
+
status: "planned"
|
|
163
|
+
});
|
|
164
|
+
current = next;
|
|
165
|
+
}
|
|
166
|
+
return { status: "ok", steps };
|
|
167
|
+
}
|
|
168
|
+
function activeStateSummary(context, projectId) {
|
|
169
|
+
return {
|
|
170
|
+
protocols: context.db.all("SELECT id, status, stage FROM protocols WHERE project_id = ? AND status NOT IN ('closed', 'cancelled') ORDER BY updated_at DESC", [projectId]),
|
|
171
|
+
runs: context.db.all("SELECT id, status, verdict FROM flow_runs WHERE project_id = ? AND status NOT IN ('done', 'cancelled', 'failed') ORDER BY updated_at DESC", [projectId]),
|
|
172
|
+
merge_queue: context.db.all("SELECT protocol_id, status FROM merge_queue WHERE project_id = ? AND status IN ('ready', 'claimed', 'requeued') ORDER BY updated_at DESC", [projectId]),
|
|
173
|
+
lane_locks: context.db.all("SELECT lane, worker_id, status FROM lane_locks WHERE project_id = ? AND status = 'active' ORDER BY updated_at DESC", [projectId])
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function activeStateBlockers(active) {
|
|
177
|
+
const blockers = [];
|
|
178
|
+
if (active.protocols.length > 0)
|
|
179
|
+
blockers.push(`active protocols: ${active.protocols.map((item) => item.id).join(", ")}`);
|
|
180
|
+
if (active.runs.length > 0)
|
|
181
|
+
blockers.push(`active runs: ${active.runs.map((item) => item.id).join(", ")}`);
|
|
182
|
+
if (active.merge_queue.length > 0)
|
|
183
|
+
blockers.push(`active merge queue items: ${active.merge_queue.map((item) => item.protocol_id).join(", ")}`);
|
|
184
|
+
if (active.lane_locks.length > 0)
|
|
185
|
+
blockers.push(`active lane locks: ${active.lane_locks.map((item) => `${item.lane}:${item.worker_id}`).join(", ")}`);
|
|
186
|
+
return blockers;
|
|
187
|
+
}
|
|
188
|
+
function readJsonObject(filePath) {
|
|
189
|
+
let parsed;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw new AppError("invalid_json", `Cannot read migration report: ${filePath}`, 2, { cause: String(error) });
|
|
195
|
+
}
|
|
196
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
197
|
+
throw new AppError("validation", "Migration report must be a JSON object", 2, { file: filePath });
|
|
198
|
+
}
|
|
199
|
+
return parsed;
|
|
200
|
+
}
|
|
201
|
+
function isAdjacentStep(value) {
|
|
202
|
+
const step = recordValue(value);
|
|
203
|
+
return Boolean(step && stringValue(step.from) && stringValue(step.to) && stringValue(step.status));
|
|
204
|
+
}
|
|
205
|
+
function recordValue(value) {
|
|
206
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
207
|
+
}
|
|
208
|
+
function stringValue(value) {
|
|
209
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
210
|
+
}
|
|
211
|
+
function parseSemver(value) {
|
|
212
|
+
const match = value.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)/);
|
|
213
|
+
if (!match?.[1] || !match[2] || !match[3])
|
|
214
|
+
return null;
|
|
215
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
|
|
216
|
+
}
|
|
217
|
+
function compareParts(left, right) {
|
|
218
|
+
if (left.major !== right.major)
|
|
219
|
+
return left.major - right.major;
|
|
220
|
+
if (left.minor !== right.minor)
|
|
221
|
+
return left.minor - right.minor;
|
|
222
|
+
return left.patch - right.patch;
|
|
223
|
+
}
|
|
224
|
+
function nextAdjacent(current, target) {
|
|
225
|
+
if (current.minor < target.minor)
|
|
226
|
+
return { major: current.major, minor: current.minor + 1, patch: 0 };
|
|
227
|
+
return { major: current.major, minor: current.minor, patch: Math.min(current.patch + 1, target.patch) };
|
|
228
|
+
}
|
|
229
|
+
function formatParts(parts) {
|
|
230
|
+
return `${parts.major}.${parts.minor}.${parts.patch}`;
|
|
231
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isActiveProtocolStatus, isActiveQueueStatus } from "../domain/contracts.js";
|
|
4
|
+
import { ensureDir } from "../storage/paths.js";
|
|
5
|
+
import { getCliVersionReport } from "./build-info.js";
|
|
6
|
+
import { projectDashboardHtmlPath, projectSummaryJsonPath } from "./config.js";
|
|
7
|
+
import { queueForProject } from "./merge-queue.js";
|
|
8
|
+
import { activeFlowSessionsForProject } from "./sessions.js";
|
|
9
|
+
import { getProjectVersionStatus } from "./version-status.js";
|
|
10
|
+
export function projectSummaryPath(context, projectId) {
|
|
11
|
+
return projectSummaryJsonPath(context, projectId);
|
|
12
|
+
}
|
|
13
|
+
export function buildProjectSummary(context, project) {
|
|
14
|
+
const versionStatus = getProjectVersionStatus({ projectRoot: fs.existsSync(project.root) ? project.root : null, rootSource: "explicit" });
|
|
15
|
+
const protocols = protocolsForProject(context, project.id);
|
|
16
|
+
const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
|
|
17
|
+
const queue = queueForProject(context, project.id);
|
|
18
|
+
const locks = context.db.all("SELECT lane, worker_id, status, expires_at FROM lane_locks WHERE project_id = ? AND status = 'active'", [project.id]);
|
|
19
|
+
const waiters = context.db.all("SELECT id, status FROM lane_waiters WHERE project_id = ? AND status = 'queued'", [project.id]);
|
|
20
|
+
const sessions = activeFlowSessionsForProject(context, project.id);
|
|
21
|
+
const openDefs = protocols.reduce((count, protocol) => count + jsonArray(protocol.active_def_json).length + jsonArray(protocol.blockers_json).length, 0);
|
|
22
|
+
const cli = getCliVersionReport().cli;
|
|
23
|
+
return {
|
|
24
|
+
schema_id: "dd-flow/project-summary@1",
|
|
25
|
+
schema_version: "1.0.0",
|
|
26
|
+
generated_at: context.now(),
|
|
27
|
+
project_id: project.id,
|
|
28
|
+
name: path.basename(project.root) || project.id,
|
|
29
|
+
root: project.root,
|
|
30
|
+
root_exists: fs.existsSync(project.root),
|
|
31
|
+
status: project.status,
|
|
32
|
+
memorybank_version: versionStatus?.memory_bank.version ?? null,
|
|
33
|
+
memorybank_status: versionStatus?.memory_bank.status ?? "missing",
|
|
34
|
+
flow_pack_status: versionStatus?.flow_pack.status ?? "missing",
|
|
35
|
+
required_engine_range: null,
|
|
36
|
+
cli_version: cli.version,
|
|
37
|
+
engine_version: null,
|
|
38
|
+
engine_status: "unknown",
|
|
39
|
+
summary_path: projectSummaryPath(context, project.id),
|
|
40
|
+
dashboard_path: projectDashboardHtmlPath(context, project.id),
|
|
41
|
+
protocol_counts: {
|
|
42
|
+
active: activeProtocols.length,
|
|
43
|
+
waiting: protocols.filter((protocol) => protocol.status === "waiting_for_user").length,
|
|
44
|
+
done: protocols.filter((protocol) => protocol.status === "closed").length,
|
|
45
|
+
total: protocols.length
|
|
46
|
+
},
|
|
47
|
+
active_protocols: activeProtocols.slice(0, 6).map((protocol) => ({
|
|
48
|
+
id: protocol.id,
|
|
49
|
+
stage: protocol.stage,
|
|
50
|
+
status: protocol.status,
|
|
51
|
+
next_action: protocol.next_action,
|
|
52
|
+
updated_at: protocol.updated_at
|
|
53
|
+
})),
|
|
54
|
+
waiting_items: protocols
|
|
55
|
+
.filter((protocol) => protocol.status === "waiting_for_user")
|
|
56
|
+
.slice(0, 6)
|
|
57
|
+
.map((protocol) => ({ id: protocol.id, stage: protocol.stage, next_action: protocol.next_action })),
|
|
58
|
+
resource_summary: {
|
|
59
|
+
queue: queue.filter((job) => isActiveQueueStatus(job.status)).length,
|
|
60
|
+
locks: locks.length,
|
|
61
|
+
waiters: waiters.length,
|
|
62
|
+
sessions: sessions.length,
|
|
63
|
+
open_defs: openDefs
|
|
64
|
+
},
|
|
65
|
+
warnings: fs.existsSync(project.root) ? [] : [{ code: "project_root_missing", path: project.root }],
|
|
66
|
+
last_activity_at: latestActivity(project, protocols)
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function publishProjectSummary(context, input) {
|
|
70
|
+
const summary = buildProjectSummary(context, input.project);
|
|
71
|
+
const output = projectSummaryPath(context, input.project.id);
|
|
72
|
+
if (input.write ?? true) {
|
|
73
|
+
writeJsonFile(output, summary);
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, project_id: input.project.id, project_root: input.project.root, summary_path: output, summary };
|
|
76
|
+
}
|
|
77
|
+
export function readPublishedProjectSummary(context, project) {
|
|
78
|
+
const file = projectSummaryPath(context, project.id);
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
81
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function projectRegistrySummary(context, project) {
|
|
88
|
+
const published = readPublishedProjectSummary(context, project);
|
|
89
|
+
const versionStatus = getProjectVersionStatus({ projectRoot: fs.existsSync(project.root) ? project.root : null, rootSource: "explicit" });
|
|
90
|
+
return {
|
|
91
|
+
project_id: project.id,
|
|
92
|
+
project_root: project.root,
|
|
93
|
+
memorybank_version: published?.memorybank_version ?? versionStatus?.memory_bank.version ?? null,
|
|
94
|
+
required_engine_range: published?.required_engine_range ?? null,
|
|
95
|
+
last_seen_cli_version: published?.cli_version ?? null,
|
|
96
|
+
last_seen_engine_version: published?.engine_version ?? null,
|
|
97
|
+
summary_path: projectSummaryPath(context, project.id),
|
|
98
|
+
last_activity_at: published?.last_activity_at ?? project.updated_at,
|
|
99
|
+
status: project.status,
|
|
100
|
+
summary_status: published ? "present" : "missing"
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function protocolsForProject(context, projectId) {
|
|
104
|
+
return context.db.all(`SELECT id, status, stage, next_action, blockers_json, active_def_json, updated_at
|
|
105
|
+
FROM protocols WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
|
|
106
|
+
}
|
|
107
|
+
function jsonArray(value) {
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(value);
|
|
110
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function latestActivity(project, protocols) {
|
|
117
|
+
return protocols.reduce((latest, protocol) => (protocol.updated_at > latest ? protocol.updated_at : latest), project.updated_at);
|
|
118
|
+
}
|
|
119
|
+
function writeJsonFile(file, value) {
|
|
120
|
+
ensureDir(path.dirname(file));
|
|
121
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
122
|
+
}
|
|
@@ -8,6 +8,8 @@ import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProje
|
|
|
8
8
|
import { dashboardMarkdownPath, globalDashboardMarkdownPath, readProjectConfig } from "./config.js";
|
|
9
9
|
import { loadProjectFlowContract } from "../domain/flow-contract.js";
|
|
10
10
|
import { buildStaticFlowGuidance } from "./flow-guidance.js";
|
|
11
|
+
import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
|
|
12
|
+
import { buildProjectSummary, projectRegistrySummary, readPublishedProjectSummary } from "./project-summary.js";
|
|
11
13
|
export function registerProject(context, input) {
|
|
12
14
|
const root = resolveProjectRoot(input.root);
|
|
13
15
|
const existing = findProjectByRoot(context, root);
|
|
@@ -89,10 +91,26 @@ export function resolveProject(context, input) {
|
|
|
89
91
|
}
|
|
90
92
|
const details = { input: input.idOrAlias };
|
|
91
93
|
if (!isFullEntityId(input.idOrAlias) && !isShortEntityId(input.idOrAlias)) {
|
|
92
|
-
details.expected = "Use full id PRJ-NNN-slug, short alias PRJ-NNN, or --root for root-based commands.";
|
|
94
|
+
details.expected = "Use full id PRJ-NNN-slug, short alias PRJ-NNN, slug, root path, or --root for root-based commands.";
|
|
93
95
|
}
|
|
94
96
|
throw new AppError("not_found", `Project is not registered: ${input.idOrAlias}`, 1, details);
|
|
95
97
|
}
|
|
98
|
+
export function requireProjectByReference(context, reference) {
|
|
99
|
+
const candidates = resolveProjectCandidates(context, reference);
|
|
100
|
+
if (candidates.length === 1) {
|
|
101
|
+
return candidates[0];
|
|
102
|
+
}
|
|
103
|
+
if (candidates.length > 1) {
|
|
104
|
+
throw new AppError("ambiguous_alias", `Project reference is ambiguous: ${reference}`, 1, {
|
|
105
|
+
input: reference,
|
|
106
|
+
candidates: candidates.map(projectSummary)
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
throw new AppError("not_found", `Project is not registered: ${reference}`, 1, {
|
|
110
|
+
input: reference,
|
|
111
|
+
expected: "Use full id PRJ-NNN-slug, short alias PRJ-NNN, slug, or project root path."
|
|
112
|
+
});
|
|
113
|
+
}
|
|
96
114
|
export function migrateProjectIds(context, input) {
|
|
97
115
|
const root = resolveProjectRoot(input.root);
|
|
98
116
|
const existing = findProjectByRoot(context, root);
|
|
@@ -160,10 +178,18 @@ export function getProjectStatus(context, input) {
|
|
|
160
178
|
const protocols = context.db.all(`SELECT id, status, stage, next_action, updated_at
|
|
161
179
|
FROM protocols
|
|
162
180
|
WHERE project_id = ?
|
|
163
|
-
ORDER BY updated_at DESC`, [project.id]).map((protocol) =>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
181
|
+
ORDER BY updated_at DESC`, [project.id]).map((protocol) => {
|
|
182
|
+
const lifecycle = normalizeProtocolLifecycle({
|
|
183
|
+
rawStage: String(protocol.stage),
|
|
184
|
+
rawStatus: String(protocol.status),
|
|
185
|
+
flowContract
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
...protocol,
|
|
189
|
+
lifecycle,
|
|
190
|
+
flow_guidance: buildStaticFlowGuidance({ stage: String(protocol.stage), status: String(protocol.status), contract: flowContract })
|
|
191
|
+
};
|
|
192
|
+
});
|
|
167
193
|
const mergeQueue = context.db.all(`SELECT protocol_id, status, claimed_by_session_id, claimed_at, attempts_count, last_reason, completed_at,
|
|
168
194
|
created_at, updated_at
|
|
169
195
|
FROM merge_queue
|
|
@@ -177,6 +203,8 @@ export function getProjectStatus(context, input) {
|
|
|
177
203
|
return {
|
|
178
204
|
ok: true,
|
|
179
205
|
project,
|
|
206
|
+
registry_summary: projectRegistrySummary(context, project),
|
|
207
|
+
project_summary: readPublishedProjectSummary(context, project) ?? buildProjectSummary(context, project),
|
|
180
208
|
config,
|
|
181
209
|
dashboard: {
|
|
182
210
|
project_markdown_path: dashboardMarkdownPath(project.root, config),
|
|
@@ -251,13 +279,17 @@ function typedProjectMetadata(project) {
|
|
|
251
279
|
return { shortId: parsed.shortId, slug: parsed.slug };
|
|
252
280
|
}
|
|
253
281
|
function resolveProjectCandidates(context, idOrAlias) {
|
|
282
|
+
if (looksLikePathReference(idOrAlias)) {
|
|
283
|
+
const byRoot = findProjectByRoot(context, normalizeStoredRoot(idOrAlias));
|
|
284
|
+
return byRoot ? [byRoot] : [];
|
|
285
|
+
}
|
|
254
286
|
if (isShortEntityId(idOrAlias)) {
|
|
255
287
|
return context.db.all("SELECT * FROM projects WHERE short_id = ? ORDER BY id ASC", [idOrAlias]);
|
|
256
288
|
}
|
|
257
289
|
if (isFullEntityId(idOrAlias)) {
|
|
258
290
|
return context.db.all("SELECT * FROM projects WHERE id = ? ORDER BY id ASC", [idOrAlias]);
|
|
259
291
|
}
|
|
260
|
-
return [];
|
|
292
|
+
return context.db.all("SELECT * FROM projects WHERE slug = ? ORDER BY id ASC", [idOrAlias]);
|
|
261
293
|
}
|
|
262
294
|
function resolveSingleProject(context, idOrAlias) {
|
|
263
295
|
return resolveProject(context, { idOrAlias }).project;
|
|
@@ -272,6 +304,9 @@ function projectSummary(project) {
|
|
|
272
304
|
state_root: project.state_root
|
|
273
305
|
};
|
|
274
306
|
}
|
|
307
|
+
function looksLikePathReference(value) {
|
|
308
|
+
return path.isAbsolute(value) || value.startsWith(".") || value.includes("/") || value.includes("\\");
|
|
309
|
+
}
|
|
275
310
|
function projectReferenceTables() {
|
|
276
311
|
return [
|
|
277
312
|
"protocols",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { defaultFlowContract, flowContractForState, normalizeStage } from "../domain/flow-contract.js";
|
|
2
|
+
export function normalizeProtocolLifecycle(input) {
|
|
3
|
+
const source = input.state ? "runtime_state" : input.rawStage || input.rawStatus ? "protocol_record" : "missing";
|
|
4
|
+
const contract = input.flowContract ?? (input.state ? flowContractForState(input.state) : defaultFlowContract);
|
|
5
|
+
const rawStage = nonEmpty(input.state?.stage) ?? nonEmpty(input.rawStage) ?? "unregistered";
|
|
6
|
+
const rawStatus = nonEmpty(input.state?.status) ?? nonEmpty(input.rawStatus) ?? "unknown";
|
|
7
|
+
const normalizedRawStage = safeNormalizeStage(rawStage, contract);
|
|
8
|
+
const diagnostics = [];
|
|
9
|
+
const mapped = mapLegacyLifecycle(normalizedRawStage, rawStatus, input.queueStatus ?? null, contract);
|
|
10
|
+
if (normalizedRawStage !== rawStage) {
|
|
11
|
+
diagnostics.push({
|
|
12
|
+
code: "lifecycle_legacy_alias_normalized",
|
|
13
|
+
severity: "info",
|
|
14
|
+
raw_stage: rawStage,
|
|
15
|
+
normalized_stage: normalizedRawStage
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (mapped.stage === "unknown") {
|
|
19
|
+
diagnostics.push({
|
|
20
|
+
code: "lifecycle_stage_unknown",
|
|
21
|
+
severity: "warning",
|
|
22
|
+
raw_stage: rawStage,
|
|
23
|
+
raw_status: rawStatus
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (["blocked", "waiting_for_user"].includes(normalizedRawStage) && !mapped.status_reason) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
code: "lifecycle_return_stage_missing",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
raw_stage: rawStage,
|
|
31
|
+
summary: `${normalizedRawStage} is a status-like legacy stage without explicit return_to_stage metadata.`
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
flow: nonEmpty(input.flow) ?? "mb_sdlc",
|
|
36
|
+
stage: mapped.stage,
|
|
37
|
+
substage: mapped.substage,
|
|
38
|
+
status: mapped.status,
|
|
39
|
+
status_reason: mapped.status_reason,
|
|
40
|
+
terminal: mapped.terminal,
|
|
41
|
+
legacy_stage: mapped.legacy_stage,
|
|
42
|
+
legacy_status: mapped.legacy_status,
|
|
43
|
+
raw_stage: rawStage,
|
|
44
|
+
raw_status: rawStatus,
|
|
45
|
+
queue_status: input.queueStatus ?? null,
|
|
46
|
+
source,
|
|
47
|
+
diagnostics
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function lifecycleIsTerminalSuccess(lifecycle) {
|
|
51
|
+
return lifecycle.terminal && lifecycle.status === "done";
|
|
52
|
+
}
|
|
53
|
+
export function lifecycleIsTerminalFailure(lifecycle) {
|
|
54
|
+
return lifecycle.terminal && ["cancelled", "failed"].includes(lifecycle.status);
|
|
55
|
+
}
|
|
56
|
+
export function lifecycleLegacyStage(lifecycle) {
|
|
57
|
+
return lifecycle.legacy_stage ?? lifecycle.raw_stage;
|
|
58
|
+
}
|
|
59
|
+
function mapLegacyLifecycle(stage, rawStatus, queueStatus, contract) {
|
|
60
|
+
if (stage === "registered") {
|
|
61
|
+
return mapped("protocol", null, "registered", "registered", rawStatus, false);
|
|
62
|
+
}
|
|
63
|
+
if (stage === "priming" || stage === "prime") {
|
|
64
|
+
return mapped("specify", "priming", statusFromRaw(rawStatus, "in_progress"), stage, rawStatus, false);
|
|
65
|
+
}
|
|
66
|
+
if (stage === "specify") {
|
|
67
|
+
return mapped("specify", null, statusFromRaw(rawStatus, "in_progress"), null, null, false);
|
|
68
|
+
}
|
|
69
|
+
if (stage === "plan") {
|
|
70
|
+
return mapped("plan", null, statusFromRaw(rawStatus, "in_progress"), null, null, false);
|
|
71
|
+
}
|
|
72
|
+
if (stage === "implementation") {
|
|
73
|
+
return mapped("code", "implementation", statusFromRaw(rawStatus, "in_progress"), "implementation", rawStatus, false);
|
|
74
|
+
}
|
|
75
|
+
if (stage === "readiness") {
|
|
76
|
+
return mapped("code", "readiness", rawStatus === "running" ? "readiness" : statusFromRaw(rawStatus, "readiness"), "readiness", rawStatus, false);
|
|
77
|
+
}
|
|
78
|
+
if (stage === "ready_for_merge") {
|
|
79
|
+
const status = queueStatus && ["ready", "requeued"].includes(queueStatus) ? "ready_for_merge" : "ready_for_merge";
|
|
80
|
+
return mapped("code", null, status, "ready_for_merge", rawStatus, false);
|
|
81
|
+
}
|
|
82
|
+
if (stage === "queued_for_merge") {
|
|
83
|
+
return mapped("merge", null, queueStatus === "claimed" ? "claimed" : "queued", "queued_for_merge", rawStatus, false);
|
|
84
|
+
}
|
|
85
|
+
if (stage === "integration") {
|
|
86
|
+
return mapped("merge", "integration", queueStatus === "claimed" ? "in_progress" : statusFromRaw(rawStatus, "in_progress"), "integration", rawStatus, false);
|
|
87
|
+
}
|
|
88
|
+
if (stage === "blocked") {
|
|
89
|
+
return mapped("unknown", null, "blocked", "blocked", rawStatus, false);
|
|
90
|
+
}
|
|
91
|
+
if (stage === "waiting_for_user") {
|
|
92
|
+
return mapped("unknown", null, "waiting_for_user", "waiting_for_user", rawStatus, false);
|
|
93
|
+
}
|
|
94
|
+
if (stage === "closed") {
|
|
95
|
+
return mapped("closed", null, "done", "closed", rawStatus, true);
|
|
96
|
+
}
|
|
97
|
+
if (stage === "cancelled") {
|
|
98
|
+
return mapped("closed", null, "cancelled", "cancelled", rawStatus, true);
|
|
99
|
+
}
|
|
100
|
+
if (contract.stages[stage]?.terminal) {
|
|
101
|
+
return mapped(stage, null, statusFromRaw(rawStatus, "done"), stage, rawStatus, true);
|
|
102
|
+
}
|
|
103
|
+
if (contract.stages[stage]) {
|
|
104
|
+
return mapped(stage, null, statusFromRaw(rawStatus, "in_progress"), null, null, false);
|
|
105
|
+
}
|
|
106
|
+
return mapped("unknown", null, statusFromRaw(rawStatus, "unknown"), stage, rawStatus, false);
|
|
107
|
+
}
|
|
108
|
+
function mapped(stage, substage, status, legacyStage, legacyStatus, terminal) {
|
|
109
|
+
return {
|
|
110
|
+
stage,
|
|
111
|
+
substage,
|
|
112
|
+
status,
|
|
113
|
+
status_reason: null,
|
|
114
|
+
terminal,
|
|
115
|
+
legacy_stage: legacyStage,
|
|
116
|
+
legacy_status: legacyStatus
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function statusFromRaw(rawStatus, fallback) {
|
|
120
|
+
if (rawStatus === "running")
|
|
121
|
+
return fallback;
|
|
122
|
+
if (rawStatus === "closed")
|
|
123
|
+
return "done";
|
|
124
|
+
if (rawStatus === "registered")
|
|
125
|
+
return "registered";
|
|
126
|
+
if (rawStatus === "waiting_user")
|
|
127
|
+
return "waiting_for_user";
|
|
128
|
+
if (rawStatus === "active")
|
|
129
|
+
return "in_progress";
|
|
130
|
+
if (rawStatus === "unknown")
|
|
131
|
+
return fallback;
|
|
132
|
+
return rawStatus || fallback;
|
|
133
|
+
}
|
|
134
|
+
function safeNormalizeStage(stage, contract) {
|
|
135
|
+
try {
|
|
136
|
+
return normalizeStage(stage, contract);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return stage;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function nonEmpty(value) {
|
|
143
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
144
|
+
}
|