@deksden-com/dd-flow-cli 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -4
- package/README.md +2 -2
- package/dist/build-info.json +6 -6
- package/dist/cli/help.js +16 -4
- package/dist/cli/run-cli.js +42 -17
- package/dist/domain/flow-contract.js +82 -3
- package/dist/domain/session-coverage.js +88 -0
- package/dist/domain/validation.js +56 -28
- package/dist/protocol/local-files.js +1 -16
- package/dist/schemas/code-stage-report.schema.json +2 -2
- package/dist/schemas/flow-contract.schema.json +152 -94
- package/dist/schemas/flow-run.schema.json +129 -23
- package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
- package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
- package/dist/schemas/merge-stage-report.schema.json +2 -2
- package/dist/schemas/plan-stage-report.schema.json +38 -335
- package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
- package/dist/schemas/protocol-plan.schema.json +197 -0
- package/dist/schemas/release-impact.schema.json +9 -5
- package/dist/schemas/session-usage.schema.json +16 -0
- package/dist/schemas/stage-finish-input.schema.json +20 -0
- package/dist/schemas/stage-prompt.schema.json +35 -0
- package/dist/schemas/stage-report.schema.json +20 -0
- package/dist/schemas/stage-start-response.schema.json +30 -0
- package/dist/schemas/timeline-event.schema.json +29 -0
- package/dist/schemas/worktrunk-workspace.schema.json +19 -0
- package/dist/services/branch-context.js +9 -4
- package/dist/services/cleanup.js +77 -0
- package/dist/services/dashboard.js +51 -26
- package/dist/services/engines.js +84 -18
- package/dist/services/hooks.js +101 -250
- package/dist/services/memory-permissions.js +77 -69
- package/dist/services/migrations.js +1 -1
- package/dist/services/plan-runtime.js +124 -0
- package/dist/services/plans.js +22 -84
- package/dist/services/projects.js +2 -1
- package/dist/services/prompts.js +26 -21
- package/dist/services/protocols.js +29 -25
- package/dist/services/run-projection.js +93 -13
- package/dist/services/runs.js +128 -61
- package/dist/services/schema-validation.js +168 -7
- package/dist/services/sessions.js +97 -73
- package/dist/services/stage-lifecycle.js +737 -0
- package/dist/services/tooling.js +285 -0
- package/dist/services/usage.js +183 -30
- package/dist/services/version-status.js +1 -1
- package/dist/services/worktrees.js +88 -39
- package/dist/storage/database.js +72 -30
- package/dist/storage/paths.js +0 -9
- package/package.json +14 -13
- package/tools/worktrunk-manifest.json +34 -0
- package/dist/schemas/flow-run-index-v3.schema.json +0 -203
- package/dist/schemas/flow-run-index.schema.json +0 -175
|
@@ -8,7 +8,60 @@ import { appendAudit } from "./audit.js";
|
|
|
8
8
|
import { cancelLaneWaitersForWorker } from "./lanes.js";
|
|
9
9
|
import { checkpointSessionUsage } from "./usage.js";
|
|
10
10
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
11
|
+
import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
|
|
11
12
|
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
13
|
+
export function recordFlowSessionObservation(context, input) {
|
|
14
|
+
const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
15
|
+
if (duplicate)
|
|
16
|
+
return { changed: false, segment_id: duplicate.id };
|
|
17
|
+
const open = context.db.get(`SELECT id, run_id, protocol_id FROM flow_session_segments
|
|
18
|
+
WHERE project_id = ? AND session_id = ? AND ended_at IS NULL ORDER BY started_at DESC, id DESC LIMIT 1`, [input.projectId, input.sessionId]);
|
|
19
|
+
const now = context.now();
|
|
20
|
+
const runId = input.runId ?? null;
|
|
21
|
+
const protocolId = input.protocolId ?? null;
|
|
22
|
+
if (open && open.run_id === runId && open.protocol_id === protocolId) {
|
|
23
|
+
return { changed: false, segment_id: open.id };
|
|
24
|
+
}
|
|
25
|
+
if (open)
|
|
26
|
+
context.db.run("UPDATE flow_session_segments SET ended_at = ? WHERE id = ? AND ended_at IS NULL", [now, open.id]);
|
|
27
|
+
const result = context.db.run(`INSERT INTO flow_session_segments
|
|
28
|
+
(project_id, session_id, run_id, protocol_id, started_at, ended_at, cwd, tool_name, event_key)
|
|
29
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`, [input.projectId, input.sessionId, runId, protocolId, now, input.cwd ?? null, input.toolName ?? null, input.eventKey]);
|
|
30
|
+
return { changed: true, segment_id: Number(result.lastInsertRowid) };
|
|
31
|
+
}
|
|
32
|
+
export function bindObservedFlowSession(context, project, payload, sessionId) {
|
|
33
|
+
if (resolveProjectRoot(payload.project_root) !== project.root) {
|
|
34
|
+
throw new AppError("project_mismatch", "Observed session payload project_root does not match hook cwd project", 1, {
|
|
35
|
+
expected: project.root,
|
|
36
|
+
actual: payload.project_root
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const session = upsertFlowSession(context, project, payload, sessionId);
|
|
40
|
+
if (session.session_kind !== "orchestrator") {
|
|
41
|
+
const now = context.now();
|
|
42
|
+
for (const unit of payload.coverage_units ?? []) {
|
|
43
|
+
if (!unit.job_id)
|
|
44
|
+
continue;
|
|
45
|
+
context.db.run(`UPDATE flow_jobs SET status = 'running', worker_session_id = ?, attempts = attempts + 1, started_at = COALESCE(started_at, ?), updated_at = ?
|
|
46
|
+
WHERE project_id = ? AND run_id = ? AND job_id = ? AND status IN ('pending', 'failed')`, [session.session_id, now, now, project.id, session.run_id ?? "", unit.job_id]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (session.run_id)
|
|
50
|
+
refreshRunSessionProjection(context, project.id, session.run_id);
|
|
51
|
+
return session;
|
|
52
|
+
}
|
|
53
|
+
export function registerFlowJob(context, input) {
|
|
54
|
+
const jobId = input.jobId ?? `JOB-${input.runId}-${input.planItemId}`.replace(/[^A-Za-z0-9_-]+/g, "-");
|
|
55
|
+
const now = context.now();
|
|
56
|
+
context.db.run(`INSERT INTO flow_jobs (job_id, project_id, run_id, protocol_id, plan_item_id, group_id, status, worker_session_id, attempts, last_error, registered_at, updated_at, started_at, finished_at)
|
|
57
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, 0, NULL, ?, ?, NULL, NULL)
|
|
58
|
+
ON CONFLICT(project_id, run_id, plan_item_id) DO UPDATE SET protocol_id = excluded.protocol_id, group_id = excluded.group_id, updated_at = excluded.updated_at`, [jobId, input.projectId, input.runId, input.protocolId ?? null, input.planItemId, input.groupId ?? null, now, now]);
|
|
59
|
+
refreshRunSessionProjection(context, input.projectId, input.runId);
|
|
60
|
+
return context.db.get("SELECT * FROM flow_jobs WHERE project_id = ? AND run_id = ? AND plan_item_id = ?", [input.projectId, input.runId, input.planItemId]) ?? { job_id: jobId };
|
|
61
|
+
}
|
|
62
|
+
export function flowJobsForRun(context, projectId, runId) {
|
|
63
|
+
return context.db.all("SELECT * FROM flow_jobs WHERE project_id = ? AND run_id = ? ORDER BY plan_item_id", [projectId, runId]);
|
|
64
|
+
}
|
|
12
65
|
export function registerFlowSession(context, input) {
|
|
13
66
|
const payload = decodeFlowSessionPayload(input);
|
|
14
67
|
const project = requireProjectByRoot(context, resolveProjectRoot(payload.project_root));
|
|
@@ -36,14 +89,19 @@ export function registerFlowSession(context, input) {
|
|
|
36
89
|
}
|
|
37
90
|
export function getFlowSessionStatus(context, input) {
|
|
38
91
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
92
|
+
const sessions = flowSessionsForProject(context, project.id, {
|
|
93
|
+
sessionId: input.sessionId,
|
|
94
|
+
workerId: input.workerId
|
|
95
|
+
});
|
|
39
96
|
return {
|
|
40
97
|
ok: true,
|
|
41
|
-
sessions
|
|
42
|
-
|
|
43
|
-
workerId: input.workerId
|
|
44
|
-
})
|
|
98
|
+
sessions,
|
|
99
|
+
coverage: reconcileSessionCoverage(sessions)
|
|
45
100
|
};
|
|
46
101
|
}
|
|
102
|
+
export function reconcileSessionCoverage(sessions) {
|
|
103
|
+
return reconcileSessionCoverageRows(sessions);
|
|
104
|
+
}
|
|
47
105
|
export function stopFlowSession(context, input) {
|
|
48
106
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
49
107
|
const session = requireFlowSession(context, project.id, input.sessionId);
|
|
@@ -126,67 +184,11 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
|
|
|
126
184
|
refreshRunSessionProjection(context, projectId, session.run_id);
|
|
127
185
|
return nextCount;
|
|
128
186
|
}
|
|
129
|
-
export function recordPendingFlowSessionBinding(context, project, input) {
|
|
130
|
-
const payload = flowSessionPayloadFromRegisterCommand(input.command);
|
|
131
|
-
if (!payload) {
|
|
132
|
-
return { recorded: false };
|
|
133
|
-
}
|
|
134
|
-
const payloadRoot = resolveProjectRoot(payload.project_root);
|
|
135
|
-
if (payloadRoot !== project.root) {
|
|
136
|
-
throw new AppError("project_mismatch", "Session register payload project_root does not match hook project", 1, {
|
|
137
|
-
expected: project.root,
|
|
138
|
-
actual: payloadRoot
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
const now = context.now();
|
|
142
|
-
context.db.run(`INSERT INTO pending_flow_session_bindings
|
|
143
|
-
(session_id, project_id, payload_json, cwd, transcript_path, turn_id, status, created_at, updated_at)
|
|
144
|
-
VALUES (?, ?, ?, ?, ?, ?, 'observed', ?, ?)
|
|
145
|
-
ON CONFLICT(session_id, project_id) DO UPDATE SET
|
|
146
|
-
payload_json = excluded.payload_json,
|
|
147
|
-
cwd = COALESCE(excluded.cwd, cwd),
|
|
148
|
-
transcript_path = COALESCE(excluded.transcript_path, transcript_path),
|
|
149
|
-
turn_id = excluded.turn_id,
|
|
150
|
-
status = 'observed',
|
|
151
|
-
updated_at = excluded.updated_at`, [
|
|
152
|
-
input.sessionId,
|
|
153
|
-
project.id,
|
|
154
|
-
JSON.stringify(sanitizeSessionPayload(payload)),
|
|
155
|
-
input.cwd ?? payload.cwd ?? null,
|
|
156
|
-
input.transcriptPath ?? payload.transcript_path ?? null,
|
|
157
|
-
input.turnId ?? null,
|
|
158
|
-
now,
|
|
159
|
-
now
|
|
160
|
-
]);
|
|
161
|
-
return { recorded: true, payload };
|
|
162
|
-
}
|
|
163
|
-
export function confirmPendingFlowSessionBinding(context, project, input) {
|
|
164
|
-
const pending = context.db.get("SELECT * FROM pending_flow_session_bindings WHERE project_id = ? AND session_id = ? AND status = 'observed'", [project.id, input.sessionId]);
|
|
165
|
-
if (!pending) {
|
|
166
|
-
return undefined;
|
|
167
|
-
}
|
|
168
|
-
const payload = normalizeFlowSessionPayload(JSON.parse(pending.payload_json));
|
|
169
|
-
const session = upsertFlowSession(context, project, {
|
|
170
|
-
...payload,
|
|
171
|
-
cwd: pending.cwd ?? payload.cwd ?? null,
|
|
172
|
-
transcript_path: pending.transcript_path ?? payload.transcript_path ?? null
|
|
173
|
-
}, input.sessionId);
|
|
174
|
-
if (session.run_id)
|
|
175
|
-
refreshRunSessionProjection(context, project.id, session.run_id);
|
|
176
|
-
context.db.run(`UPDATE pending_flow_session_bindings SET status = 'confirmed', updated_at = ?
|
|
177
|
-
WHERE project_id = ? AND session_id = ?`, [context.now(), project.id, input.sessionId]);
|
|
178
|
-
appendAudit(context, {
|
|
179
|
-
projectId: project.id,
|
|
180
|
-
eventType: "flow_session.bound",
|
|
181
|
-
payload: { project_id: project.id, session_id: input.sessionId, flow_kind: session.flow_kind },
|
|
182
|
-
...(session.protocol_id ? { protocolId: session.protocol_id } : {})
|
|
183
|
-
});
|
|
184
|
-
return session;
|
|
185
|
-
}
|
|
186
187
|
export function flowSessionPayloadFromRegisterCommand(command) {
|
|
187
|
-
if (
|
|
188
|
+
if (/\bdd-flow\s+stage\s+start\b/.test(command))
|
|
189
|
+
return flowSessionPayloadFromStageStartCommand(command);
|
|
190
|
+
if (!/\bdd-flow\s+session\s+register\b/.test(command))
|
|
188
191
|
return undefined;
|
|
189
|
-
}
|
|
190
192
|
const payloadBase64 = optionFromCommand(command, "payload-base64");
|
|
191
193
|
const payloadJson = optionFromCommand(command, "payload-json");
|
|
192
194
|
const payloadFile = optionFromCommand(command, "payload-file");
|
|
@@ -195,6 +197,25 @@ export function flowSessionPayloadFromRegisterCommand(command) {
|
|
|
195
197
|
}
|
|
196
198
|
return decodeFlowSessionPayload({ payloadBase64, payloadJson, payloadFile });
|
|
197
199
|
}
|
|
200
|
+
function flowSessionPayloadFromStageStartCommand(command) {
|
|
201
|
+
const projectRoot = optionFromCommand(command, "project-root");
|
|
202
|
+
const stage = optionFromCommand(command, "stage");
|
|
203
|
+
const run = command.match(/\bdd-flow\s+stage\s+start\s+([^\s]+)/)?.[1];
|
|
204
|
+
if (!projectRoot || !stage || !run || run.startsWith("--"))
|
|
205
|
+
return undefined;
|
|
206
|
+
return {
|
|
207
|
+
project_root: projectRoot,
|
|
208
|
+
flow_kind: ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning",
|
|
209
|
+
run_id: run,
|
|
210
|
+
protocol_id: null,
|
|
211
|
+
worker_id: null,
|
|
212
|
+
workspace_path: null,
|
|
213
|
+
continuation_policy: "go_router",
|
|
214
|
+
session_kind: "orchestrator",
|
|
215
|
+
current_stage: stage,
|
|
216
|
+
coverage_units: []
|
|
217
|
+
};
|
|
218
|
+
}
|
|
198
219
|
function decodeFlowSessionPayload(input) {
|
|
199
220
|
if (input.payloadFile) {
|
|
200
221
|
return normalizeFlowSessionPayload(parseJsonObject(fs.readFileSync(input.payloadFile, "utf8"), "session payload"));
|
|
@@ -320,6 +341,10 @@ function markSessionStopped(context, project, session, reason) {
|
|
|
320
341
|
context.db.run(`UPDATE flow_sessions
|
|
321
342
|
SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
322
343
|
WHERE project_id = ? AND session_id = ?`, [reason, now, now, project.id, session.session_id]);
|
|
344
|
+
if (session.run_id && session.session_kind !== "orchestrator") {
|
|
345
|
+
context.db.run(`UPDATE flow_jobs SET status = 'pending', worker_session_id = NULL, last_error = ?, updated_at = ?
|
|
346
|
+
WHERE project_id = ? AND run_id = ? AND worker_session_id = ? AND status = 'running'`, [reason, now, project.id, session.run_id, session.session_id]);
|
|
347
|
+
}
|
|
323
348
|
if (session.run_id)
|
|
324
349
|
appendFlowRunTimelineEvent(context, project.id, session.run_id, { type: "session_stopped", session_id: session.session_id });
|
|
325
350
|
if (session.run_id)
|
|
@@ -374,13 +399,6 @@ function optionFromCommand(command, key) {
|
|
|
374
399
|
const match = command.match(pattern);
|
|
375
400
|
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
376
401
|
}
|
|
377
|
-
function sanitizeSessionPayload(payload) {
|
|
378
|
-
return {
|
|
379
|
-
...payload,
|
|
380
|
-
next_action: redactString(payload.next_action ?? null),
|
|
381
|
-
metadata: sanitizeValue(payload.metadata ?? {})
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
402
|
function sanitizeValue(value) {
|
|
385
403
|
if (Array.isArray(value)) {
|
|
386
404
|
return value.map(sanitizeValue);
|
|
@@ -427,9 +445,8 @@ function sessionKind(payload) {
|
|
|
427
445
|
function normalizeCoverageUnits(value) {
|
|
428
446
|
if (value === undefined || value === null)
|
|
429
447
|
return [];
|
|
430
|
-
if (!Array.isArray(value))
|
|
448
|
+
if (!Array.isArray(value))
|
|
431
449
|
throw new AppError("validation", "coverage_units must be an array", 2);
|
|
432
|
-
}
|
|
433
450
|
return value.map((item, index) => {
|
|
434
451
|
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
435
452
|
throw new AppError("validation", `coverage_units[${index}] must be an object`, 2);
|
|
@@ -438,7 +455,14 @@ function normalizeCoverageUnits(value) {
|
|
|
438
455
|
if (typeof object.unit_id !== "string" || object.unit_id.length === 0) {
|
|
439
456
|
throw new AppError("validation", `coverage_units[${index}].unit_id is required`, 2);
|
|
440
457
|
}
|
|
441
|
-
const optional = (key) =>
|
|
458
|
+
const optional = (key) => {
|
|
459
|
+
const field = object[key];
|
|
460
|
+
if (field === undefined || field === null)
|
|
461
|
+
return null;
|
|
462
|
+
if (typeof field === "string")
|
|
463
|
+
return field;
|
|
464
|
+
throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2);
|
|
465
|
+
};
|
|
442
466
|
return { unit_id: object.unit_id, group_id: optional("group_id"), job_id: optional("job_id"), kind: optional("kind") };
|
|
443
467
|
});
|
|
444
468
|
}
|