@deksden-com/dd-flow-cli 0.2.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 +76 -5
- package/dist/build-info.json +10 -6
- package/dist/cli/help.js +165 -20
- package/dist/cli/run-cli.js +427 -17
- package/dist/schemas/compatibility.schema.json +105 -0
- package/dist/schemas/engine-manifest.schema.json +61 -0
- package/dist/schemas/flow-guidance.schema.json +90 -0
- package/dist/schemas/flow-run-index.schema.json +30 -4
- package/dist/schemas/global-dashboard-data.schema.json +126 -0
- package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
- package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
- package/dist/schemas/plan-stage-report.schema.json +83 -0
- package/dist/schemas/project-dashboard-data.schema.json +122 -0
- package/dist/schemas/project-flow-pack-manifest.schema.json +5 -1
- package/dist/schemas/project-summary.schema.json +73 -0
- package/dist/schemas/protocol-dashboard-data.schema.json +112 -0
- package/dist/schemas/status-report.schema.json +38 -2
- package/dist/schemas/version-report.schema.json +22 -0
- package/dist/services/build-info.js +26 -3
- package/dist/services/canon.js +93 -22
- package/dist/services/cleanup.js +45 -1
- package/dist/services/cli-operation-classifier.js +104 -0
- package/dist/services/compatibility-preflight.js +124 -0
- package/dist/services/config.js +31 -0
- package/dist/services/dashboard-targets.js +95 -0
- package/dist/services/dashboard.js +972 -10
- package/dist/services/engines.js +532 -0
- package/dist/services/flow-guidance.js +221 -0
- package/dist/services/hooks.js +1 -1
- package/dist/services/ids.js +106 -0
- package/dist/services/lanes.js +333 -1
- package/dist/services/merge-queue.js +106 -16
- package/dist/services/merge-worker.js +67 -4
- package/dist/services/migrations.js +231 -0
- package/dist/services/project-summary.js +122 -0
- package/dist/services/projects.js +44 -3
- package/dist/services/protocol-lifecycle.js +144 -0
- package/dist/services/protocols.js +660 -7
- package/dist/services/runs.js +98 -21
- package/dist/services/schema-validation.js +84 -4
- package/dist/services/sessions.js +21 -4
- package/dist/services/status.js +199 -1
- package/dist/services/version-status.js +59 -9
- package/dist/storage/database.js +31 -0
- package/dist/storage/paths.js +33 -0
- package/package.json +3 -2
|
@@ -4,6 +4,8 @@ import { appendAudit } from "./audit.js";
|
|
|
4
4
|
import { ensureLaneWorkspace, acquireLaneLock, releaseLaneLock, expireProjectLaneLocks } from "./lanes.js";
|
|
5
5
|
import { claimNextMergeJob, queueForProject } from "./merge-queue.js";
|
|
6
6
|
import { requireProjectByRoot } from "./projects.js";
|
|
7
|
+
import { buildStaticFlowGuidance } from "./flow-guidance.js";
|
|
8
|
+
import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
7
9
|
import { registerFlowSession, stopMergeWorker } from "./sessions.js";
|
|
8
10
|
export function getMergeWorkerStatus(context, input) {
|
|
9
11
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -11,7 +13,7 @@ export function getMergeWorkerStatus(context, input) {
|
|
|
11
13
|
ok: true,
|
|
12
14
|
project: { id: project.id, root: project.root },
|
|
13
15
|
merge_worker: detectMergeWorkerState(context, project.id),
|
|
14
|
-
queue: queueForProject(context, project.id)
|
|
16
|
+
queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job }))
|
|
15
17
|
};
|
|
16
18
|
}
|
|
17
19
|
export function startMergeWorker(context, input) {
|
|
@@ -19,7 +21,16 @@ export function startMergeWorker(context, input) {
|
|
|
19
21
|
const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
|
|
20
22
|
const detection = detectMergeWorkerState(context, project.id);
|
|
21
23
|
if (detection.state !== "clear") {
|
|
22
|
-
return {
|
|
24
|
+
return {
|
|
25
|
+
ok: true,
|
|
26
|
+
started: false,
|
|
27
|
+
claimed: false,
|
|
28
|
+
mode: "status_only",
|
|
29
|
+
outcome: "status_only",
|
|
30
|
+
reason: "merge_worker_already_active",
|
|
31
|
+
merge_worker: detection,
|
|
32
|
+
queue: queueForProject(context, project.id)
|
|
33
|
+
};
|
|
23
34
|
}
|
|
24
35
|
ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath, branch: input.branch });
|
|
25
36
|
const registered = registerFlowSession(context, {
|
|
@@ -80,7 +91,15 @@ export function oneShotMergeClaim(context, input) {
|
|
|
80
91
|
const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
|
|
81
92
|
const detection = detectMergeWorkerState(context, project.id);
|
|
82
93
|
if (detection.state !== "clear") {
|
|
83
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
claimed: false,
|
|
97
|
+
mode: "status_only",
|
|
98
|
+
outcome: "status_only",
|
|
99
|
+
reason: "active_merge_worker_or_lock",
|
|
100
|
+
merge_worker: detection,
|
|
101
|
+
queue: queueForProject(context, project.id)
|
|
102
|
+
};
|
|
84
103
|
}
|
|
85
104
|
ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath });
|
|
86
105
|
const lock = acquireLaneLock(context, {
|
|
@@ -101,7 +120,51 @@ export function oneShotMergeClaim(context, input) {
|
|
|
101
120
|
reason: "one-shot merge no job"
|
|
102
121
|
});
|
|
103
122
|
}
|
|
104
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
claimed: Boolean(claimed.job),
|
|
126
|
+
mode: "one_shot",
|
|
127
|
+
outcome: claimed.job ? "claimed" : "empty",
|
|
128
|
+
queue_item: claimed.job ? withJobGuidance(context, { ...claimed.job }) : null,
|
|
129
|
+
protocol: claimed.job
|
|
130
|
+
? {
|
|
131
|
+
id: String(claimed.job.protocol_id ?? ""),
|
|
132
|
+
queue_status: String(claimed.job.status ?? ""),
|
|
133
|
+
project_id: String(claimed.job.project_id ?? "")
|
|
134
|
+
}
|
|
135
|
+
: null,
|
|
136
|
+
claim: claimed.job?.claimed_by_session_id
|
|
137
|
+
? {
|
|
138
|
+
protocol_id: String(claimed.job.protocol_id ?? ""),
|
|
139
|
+
worker_id: String(claimed.job.claimed_by_session_id ?? ""),
|
|
140
|
+
claimed_at: claimed.job.claimed_at ?? null,
|
|
141
|
+
status: claimed.job.status ?? null
|
|
142
|
+
}
|
|
143
|
+
: null,
|
|
144
|
+
job: claimed.job ? withJobGuidance(context, { ...claimed.job }) : null,
|
|
145
|
+
lock: claimed.job ? lock.lock : null,
|
|
146
|
+
flow_guidance: claimed.job ? withJobGuidance(context, { ...claimed.job }).flow_guidance : undefined
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function withJobGuidance(context, job) {
|
|
150
|
+
const protocolId = typeof job.protocol_id === "string" ? job.protocol_id : "";
|
|
151
|
+
if (!protocolId)
|
|
152
|
+
return job;
|
|
153
|
+
try {
|
|
154
|
+
const protocol = requireProtocol(context, protocolId);
|
|
155
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
156
|
+
return {
|
|
157
|
+
...job,
|
|
158
|
+
flow_guidance: buildStaticFlowGuidance({
|
|
159
|
+
stage: state.stage,
|
|
160
|
+
...(state.flow_contract ? { contract: state.flow_contract } : {}),
|
|
161
|
+
queueStatus: String(job.status ?? "")
|
|
162
|
+
})
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return job;
|
|
167
|
+
}
|
|
105
168
|
}
|
|
106
169
|
function detectMergeWorkerState(context, projectId) {
|
|
107
170
|
expireProjectLaneLocks(context, projectId);
|
|
@@ -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
|
+
}
|
|
@@ -6,6 +6,10 @@ import { projectRuntimeRoot, resolveProjectRoot } from "../storage/paths.js";
|
|
|
6
6
|
import { appendAudit } from "./audit.js";
|
|
7
7
|
import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
|
|
8
8
|
import { dashboardMarkdownPath, globalDashboardMarkdownPath, readProjectConfig } from "./config.js";
|
|
9
|
+
import { loadProjectFlowContract } from "../domain/flow-contract.js";
|
|
10
|
+
import { buildStaticFlowGuidance } from "./flow-guidance.js";
|
|
11
|
+
import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
|
|
12
|
+
import { buildProjectSummary, projectRegistrySummary, readPublishedProjectSummary } from "./project-summary.js";
|
|
9
13
|
export function registerProject(context, input) {
|
|
10
14
|
const root = resolveProjectRoot(input.root);
|
|
11
15
|
const existing = findProjectByRoot(context, root);
|
|
@@ -87,10 +91,26 @@ export function resolveProject(context, input) {
|
|
|
87
91
|
}
|
|
88
92
|
const details = { input: input.idOrAlias };
|
|
89
93
|
if (!isFullEntityId(input.idOrAlias) && !isShortEntityId(input.idOrAlias)) {
|
|
90
|
-
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.";
|
|
91
95
|
}
|
|
92
96
|
throw new AppError("not_found", `Project is not registered: ${input.idOrAlias}`, 1, details);
|
|
93
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
|
+
}
|
|
94
114
|
export function migrateProjectIds(context, input) {
|
|
95
115
|
const root = resolveProjectRoot(input.root);
|
|
96
116
|
const existing = findProjectByRoot(context, root);
|
|
@@ -154,10 +174,22 @@ export function getProjectStatus(context, input) {
|
|
|
154
174
|
if (!project) {
|
|
155
175
|
throw new AppError("not_found", `Project is not registered: ${root}`, 1);
|
|
156
176
|
}
|
|
177
|
+
const flowContract = loadProjectFlowContract(root);
|
|
157
178
|
const protocols = context.db.all(`SELECT id, status, stage, next_action, updated_at
|
|
158
179
|
FROM protocols
|
|
159
180
|
WHERE project_id = ?
|
|
160
|
-
ORDER BY updated_at DESC`, [project.id])
|
|
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
|
+
});
|
|
161
193
|
const mergeQueue = context.db.all(`SELECT protocol_id, status, claimed_by_session_id, claimed_at, attempts_count, last_reason, completed_at,
|
|
162
194
|
created_at, updated_at
|
|
163
195
|
FROM merge_queue
|
|
@@ -171,6 +203,8 @@ export function getProjectStatus(context, input) {
|
|
|
171
203
|
return {
|
|
172
204
|
ok: true,
|
|
173
205
|
project,
|
|
206
|
+
registry_summary: projectRegistrySummary(context, project),
|
|
207
|
+
project_summary: readPublishedProjectSummary(context, project) ?? buildProjectSummary(context, project),
|
|
174
208
|
config,
|
|
175
209
|
dashboard: {
|
|
176
210
|
project_markdown_path: dashboardMarkdownPath(project.root, config),
|
|
@@ -245,13 +279,17 @@ function typedProjectMetadata(project) {
|
|
|
245
279
|
return { shortId: parsed.shortId, slug: parsed.slug };
|
|
246
280
|
}
|
|
247
281
|
function resolveProjectCandidates(context, idOrAlias) {
|
|
282
|
+
if (looksLikePathReference(idOrAlias)) {
|
|
283
|
+
const byRoot = findProjectByRoot(context, normalizeStoredRoot(idOrAlias));
|
|
284
|
+
return byRoot ? [byRoot] : [];
|
|
285
|
+
}
|
|
248
286
|
if (isShortEntityId(idOrAlias)) {
|
|
249
287
|
return context.db.all("SELECT * FROM projects WHERE short_id = ? ORDER BY id ASC", [idOrAlias]);
|
|
250
288
|
}
|
|
251
289
|
if (isFullEntityId(idOrAlias)) {
|
|
252
290
|
return context.db.all("SELECT * FROM projects WHERE id = ? ORDER BY id ASC", [idOrAlias]);
|
|
253
291
|
}
|
|
254
|
-
return [];
|
|
292
|
+
return context.db.all("SELECT * FROM projects WHERE slug = ? ORDER BY id ASC", [idOrAlias]);
|
|
255
293
|
}
|
|
256
294
|
function resolveSingleProject(context, idOrAlias) {
|
|
257
295
|
return resolveProject(context, { idOrAlias }).project;
|
|
@@ -266,6 +304,9 @@ function projectSummary(project) {
|
|
|
266
304
|
state_root: project.state_root
|
|
267
305
|
};
|
|
268
306
|
}
|
|
307
|
+
function looksLikePathReference(value) {
|
|
308
|
+
return path.isAbsolute(value) || value.startsWith(".") || value.includes("/") || value.includes("\\");
|
|
309
|
+
}
|
|
269
310
|
function projectReferenceTables() {
|
|
270
311
|
return [
|
|
271
312
|
"protocols",
|