@deksden-com/dd-flow-cli 0.1.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.
Files changed (35) hide show
  1. package/README.md +274 -0
  2. package/dist/cli/help.js +308 -0
  3. package/dist/cli/run-cli.js +945 -0
  4. package/dist/cli.js +4 -0
  5. package/dist/domain/contracts.js +57 -0
  6. package/dist/domain/entity-ids.js +47 -0
  7. package/dist/domain/flow-contract.js +233 -0
  8. package/dist/domain/validation.js +91 -0
  9. package/dist/protocol/local-files.js +141 -0
  10. package/dist/runtime/context.js +11 -0
  11. package/dist/schemas/code-stage-report.schema.json +181 -0
  12. package/dist/schemas/flow-run-index.schema.json +129 -0
  13. package/dist/schemas/mb-upgrade-review-data.schema.json +813 -0
  14. package/dist/schemas/memorybank-permissions-preflight.schema.json +154 -0
  15. package/dist/schemas/merge-stage-report.schema.json +135 -0
  16. package/dist/services/audit.js +19 -0
  17. package/dist/services/cleanup.js +310 -0
  18. package/dist/services/config.js +143 -0
  19. package/dist/services/dashboard.js +436 -0
  20. package/dist/services/hooks.js +929 -0
  21. package/dist/services/lanes.js +327 -0
  22. package/dist/services/memory-permissions.js +344 -0
  23. package/dist/services/merge-queue.js +333 -0
  24. package/dist/services/plans.js +149 -0
  25. package/dist/services/projects.js +286 -0
  26. package/dist/services/protocols.js +606 -0
  27. package/dist/services/runs.js +359 -0
  28. package/dist/services/schema-validation.js +185 -0
  29. package/dist/services/sessions.js +365 -0
  30. package/dist/services/worktrees.js +204 -0
  31. package/dist/shared/errors.js +14 -0
  32. package/dist/shared/json.js +17 -0
  33. package/dist/storage/database.js +325 -0
  34. package/dist/storage/paths.js +56 -0
  35. package/package.json +44 -0
@@ -0,0 +1,154 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "dd-flow/memorybank-permissions-preflight@1",
4
+ "title": "Memory Bank permissions preflight",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schema_id",
9
+ "ok",
10
+ "exit_code",
11
+ "flow",
12
+ "mode",
13
+ "platform",
14
+ "targets",
15
+ "checks",
16
+ "summary",
17
+ "remediation"
18
+ ],
19
+ "properties": {
20
+ "schema_id": {
21
+ "const": "dd-flow/memorybank-permissions-preflight@1"
22
+ },
23
+ "ok": {
24
+ "type": "boolean"
25
+ },
26
+ "exit_code": {
27
+ "type": "integer",
28
+ "enum": [0, 1]
29
+ },
30
+ "flow": {
31
+ "type": "string",
32
+ "enum": ["mb-init", "mb-upgrade", "mb-audit", "mb-fix", "mb-upgrade-review", "custom"]
33
+ },
34
+ "mode": {
35
+ "type": "string",
36
+ "enum": ["read", "write", "repair", "report_only"]
37
+ },
38
+ "platform": {
39
+ "type": "object",
40
+ "additionalProperties": false,
41
+ "required": ["os", "family", "cwd", "user", "group"],
42
+ "properties": {
43
+ "os": { "type": "string" },
44
+ "family": { "type": "string", "enum": ["posix", "windows"] },
45
+ "cwd": { "type": "string" },
46
+ "user": { "type": "string" },
47
+ "group": { "type": "string" }
48
+ }
49
+ },
50
+ "targets": {
51
+ "type": "object",
52
+ "additionalProperties": false,
53
+ "required": ["project_root", "memory_bank", "tasks"],
54
+ "properties": {
55
+ "project_root": { "type": "string" },
56
+ "memory_bank": { "type": "string" },
57
+ "tasks": { "type": "string" }
58
+ }
59
+ },
60
+ "checks": {
61
+ "type": "array",
62
+ "items": { "$ref": "#/$defs/check" }
63
+ },
64
+ "summary": {
65
+ "type": "object",
66
+ "additionalProperties": false,
67
+ "required": ["errors", "warnings", "can_continue"],
68
+ "properties": {
69
+ "errors": { "type": "integer", "minimum": 0 },
70
+ "warnings": { "type": "integer", "minimum": 0 },
71
+ "can_continue": { "type": "boolean" }
72
+ }
73
+ },
74
+ "remediation": {
75
+ "type": "array",
76
+ "items": { "$ref": "#/$defs/remediation" }
77
+ }
78
+ },
79
+ "$defs": {
80
+ "check": {
81
+ "type": "object",
82
+ "additionalProperties": false,
83
+ "required": ["id", "severity", "path", "operation", "status", "reason", "side_effect"],
84
+ "properties": {
85
+ "id": { "type": "string", "minLength": 1 },
86
+ "severity": { "type": "string", "enum": ["info", "warning", "error"] },
87
+ "path": { "type": "string" },
88
+ "operation": {
89
+ "type": "string",
90
+ "enum": [
91
+ "read_existing_file",
92
+ "write_existing_file",
93
+ "create_file",
94
+ "create_directory",
95
+ "delete_probe",
96
+ "stat_metadata",
97
+ "detect_acl",
98
+ "detect_flags"
99
+ ]
100
+ },
101
+ "status": {
102
+ "type": "string",
103
+ "enum": ["passed", "failed", "warning", "skipped", "unknown"]
104
+ },
105
+ "reason": {
106
+ "type": "string",
107
+ "enum": [
108
+ "ok",
109
+ "not_found",
110
+ "not_writable",
111
+ "not_readable",
112
+ "owner_mismatch",
113
+ "acl_denied",
114
+ "read_only_attribute",
115
+ "immutable_flag",
116
+ "unsupported_platform",
117
+ "probe_failed",
118
+ "unknown"
119
+ ]
120
+ },
121
+ "fs_error": {
122
+ "type": "object",
123
+ "additionalProperties": false,
124
+ "required": ["message"],
125
+ "properties": {
126
+ "code": { "type": "string" },
127
+ "message": { "type": "string" }
128
+ }
129
+ },
130
+ "owner": { "type": "string" },
131
+ "group": { "type": "string" },
132
+ "mode": { "type": "string" },
133
+ "side_effect": {
134
+ "type": "string",
135
+ "enum": ["none", "temporary_probe_created_and_removed", "temporary_probe_cleanup_failed"]
136
+ }
137
+ }
138
+ },
139
+ "remediation": {
140
+ "type": "object",
141
+ "additionalProperties": false,
142
+ "required": ["platform", "title", "commands", "requires_user_confirmation"],
143
+ "properties": {
144
+ "platform": { "type": "string" },
145
+ "title": { "type": "string" },
146
+ "commands": {
147
+ "type": "array",
148
+ "items": { "type": "string" }
149
+ },
150
+ "requires_user_confirmation": { "type": "boolean" }
151
+ }
152
+ }
153
+ }
154
+ }
@@ -0,0 +1,135 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "dd-flow/merge-stage-report@1",
4
+ "title": "Merge stage report data",
5
+ "type": "object",
6
+ "additionalProperties": true,
7
+ "required": [
8
+ "schema_id",
9
+ "run",
10
+ "stage",
11
+ "project",
12
+ "subject",
13
+ "overall",
14
+ "breadcrumbs",
15
+ "merge_job",
16
+ "git",
17
+ "checks",
18
+ "cleanup",
19
+ "defs"
20
+ ],
21
+ "properties": {
22
+ "schema_id": { "const": "dd-flow/merge-stage-report@1" },
23
+ "run": { "$ref": "#/definitions/run" },
24
+ "stage": { "$ref": "#/definitions/stage" },
25
+ "project": { "$ref": "#/definitions/namedEntity" },
26
+ "subject": { "$ref": "#/definitions/namedEntity" },
27
+ "overall": { "$ref": "#/definitions/overall" },
28
+ "breadcrumbs": {
29
+ "type": "array",
30
+ "items": { "$ref": "#/definitions/breadcrumb" }
31
+ },
32
+ "merge_job": {
33
+ "type": "object",
34
+ "additionalProperties": true,
35
+ "required": ["status", "claimed_by", "queue_result"],
36
+ "properties": {
37
+ "status": { "type": "string", "minLength": 1 },
38
+ "claimed_by": { "type": "string" },
39
+ "queue_result": { "type": "string", "minLength": 1 }
40
+ }
41
+ },
42
+ "git": {
43
+ "type": "object",
44
+ "additionalProperties": true,
45
+ "required": ["source_branch", "target_branch", "result_commit"],
46
+ "properties": {
47
+ "source_branch": { "type": "string", "minLength": 1 },
48
+ "target_branch": { "type": "string", "minLength": 1 },
49
+ "result_commit": { "type": "string" }
50
+ }
51
+ },
52
+ "checks": {
53
+ "type": "array",
54
+ "items": { "$ref": "#/definitions/check" }
55
+ },
56
+ "cleanup": {
57
+ "type": "array",
58
+ "items": { "$ref": "#/definitions/check" }
59
+ },
60
+ "defs": {
61
+ "type": "array",
62
+ "items": { "$ref": "#/definitions/deferral" }
63
+ }
64
+ },
65
+ "definitions": {
66
+ "run": {
67
+ "type": "object",
68
+ "additionalProperties": true,
69
+ "required": ["run_id", "run_index"],
70
+ "properties": {
71
+ "run_id": { "type": "string", "pattern": "^RUN-[0-9]{3}-[a-z0-9]+(?:-[a-z0-9]+)*$" },
72
+ "run_index": { "type": "string", "minLength": 1 }
73
+ }
74
+ },
75
+ "stage": {
76
+ "type": "object",
77
+ "additionalProperties": true,
78
+ "required": ["name", "dir", "status"],
79
+ "properties": {
80
+ "name": { "type": "string", "minLength": 1 },
81
+ "dir": { "type": "string", "pattern": "^[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$" },
82
+ "status": { "type": "string", "minLength": 1 }
83
+ }
84
+ },
85
+ "namedEntity": {
86
+ "type": "object",
87
+ "additionalProperties": true,
88
+ "required": ["id", "title"],
89
+ "properties": {
90
+ "id": { "type": "string", "minLength": 1 },
91
+ "title": { "type": "string", "minLength": 1 }
92
+ }
93
+ },
94
+ "overall": {
95
+ "type": "object",
96
+ "additionalProperties": true,
97
+ "required": ["verdict", "summary", "next_action"],
98
+ "properties": {
99
+ "verdict": { "type": "string", "minLength": 1 },
100
+ "summary": { "type": "string", "minLength": 1 },
101
+ "next_action": { "type": "string", "minLength": 1 }
102
+ }
103
+ },
104
+ "breadcrumb": {
105
+ "type": "object",
106
+ "additionalProperties": false,
107
+ "required": ["label", "href", "status"],
108
+ "properties": {
109
+ "label": { "type": "string", "minLength": 1 },
110
+ "href": { "type": "string" },
111
+ "status": { "type": "string", "minLength": 1 }
112
+ }
113
+ },
114
+ "check": {
115
+ "type": "object",
116
+ "additionalProperties": true,
117
+ "required": ["name", "status"],
118
+ "properties": {
119
+ "name": { "type": "string", "minLength": 1 },
120
+ "status": { "type": "string", "minLength": 1 },
121
+ "evidence": { "type": "string" }
122
+ }
123
+ },
124
+ "deferral": {
125
+ "type": "object",
126
+ "additionalProperties": true,
127
+ "required": ["id", "status", "summary"],
128
+ "properties": {
129
+ "id": { "type": "string", "minLength": 1 },
130
+ "status": { "type": "string", "minLength": 1 },
131
+ "summary": { "type": "string", "minLength": 1 }
132
+ }
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,19 @@
1
+ export function appendAudit(context, input) {
2
+ context.db.run(`INSERT INTO audit_events
3
+ (protocol_id, project_id, event_type, forced, reason, payload_json, created_at)
4
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
5
+ input.protocolId ?? null,
6
+ input.projectId ?? null,
7
+ input.eventType,
8
+ input.forced ? 1 : 0,
9
+ input.reason ?? null,
10
+ JSON.stringify(input.payload),
11
+ context.now()
12
+ ]);
13
+ }
14
+ export function getAuditEvents(context, protocolId) {
15
+ return context.db.all(`SELECT id, protocol_id, project_id, event_type, forced, reason, payload_json, created_at
16
+ FROM audit_events
17
+ WHERE protocol_id = ?
18
+ ORDER BY id ASC`, [protocolId]);
19
+ }
@@ -0,0 +1,310 @@
1
+ import fs from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { parseJsonObject } from "../shared/json.js";
6
+ import { ensureReadableFile } from "../storage/database.js";
7
+ import { projectCheckoutRoot, resolveProjectRoot } from "../storage/paths.js";
8
+ import { appendAudit } from "./audit.js";
9
+ import { requireProjectByRoot } from "./projects.js";
10
+ import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
11
+ const actionKinds = new Set([
12
+ "repair_protocol_state",
13
+ "cancel_queue_job",
14
+ "close_flow_session",
15
+ "expire_lane_lock",
16
+ "close_worktree_record"
17
+ ]);
18
+ export function cleanupScan(context, input) {
19
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
20
+ const actions = [];
21
+ const findings = [];
22
+ const protocols = context.db.all("SELECT id, status, stage, state_path FROM protocols WHERE project_id = ? ORDER BY id ASC", [project.id]);
23
+ for (const protocol of protocols) {
24
+ const terminal = ["closed", "cancelled"].includes(protocol.status) || ["closed", "cancelled"].includes(protocol.stage);
25
+ if (!fs.existsSync(protocol.state_path)) {
26
+ pushFinding(findings, actions, "missing_protocol_runtime_state", "warning", {
27
+ protocol_id: protocol.id,
28
+ path: protocol.state_path,
29
+ action: { kind: "repair_protocol_state", project_id: project.id, protocol_id: protocol.id }
30
+ });
31
+ }
32
+ if (terminal) {
33
+ const queue = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ? AND status IN ('ready', 'requeued', 'claimed')", [protocol.id]);
34
+ if (queue) {
35
+ pushFinding(findings, actions, "active_queue_for_terminal_protocol", "warning", {
36
+ protocol_id: protocol.id,
37
+ queue_status: queue.status,
38
+ action: { kind: "cancel_queue_job", project_id: project.id, protocol_id: protocol.id }
39
+ });
40
+ }
41
+ }
42
+ }
43
+ const orphanQueueJobs = context.db.all(`SELECT mq.protocol_id, mq.status FROM merge_queue mq
44
+ LEFT JOIN protocols p ON p.id = mq.protocol_id
45
+ WHERE mq.project_id = ?
46
+ AND mq.status IN ('ready', 'requeued')
47
+ AND p.id IS NULL
48
+ ORDER BY mq.created_at ASC, mq.id ASC`, [project.id]);
49
+ for (const job of orphanQueueJobs) {
50
+ pushFinding(findings, actions, "orphan_ready_merge_job", "warning", {
51
+ protocol_id: job.protocol_id,
52
+ queue_status: job.status,
53
+ action: { kind: "cancel_queue_job", project_id: project.id, protocol_id: job.protocol_id }
54
+ });
55
+ }
56
+ const staleSessions = context.db.all(`SELECT session_id, protocol_id, workspace_path FROM flow_sessions
57
+ WHERE project_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
58
+ ORDER BY updated_at ASC, session_id ASC`, [project.id]);
59
+ for (const session of staleSessions) {
60
+ const protocol = session.protocol_id
61
+ ? context.db.get("SELECT status, stage FROM protocols WHERE id = ?", [session.protocol_id])
62
+ : undefined;
63
+ const missingWorkspace = !fs.existsSync(session.workspace_path);
64
+ const terminalProtocol = Boolean(protocol && (["closed", "cancelled"].includes(protocol.status) || ["closed", "cancelled"].includes(protocol.stage)));
65
+ if (missingWorkspace || terminalProtocol) {
66
+ pushFinding(findings, actions, "stale_flow_session", "warning", {
67
+ session_id: session.session_id,
68
+ protocol_id: session.protocol_id,
69
+ missing_workspace: missingWorkspace,
70
+ terminal_protocol: terminalProtocol,
71
+ action: { kind: "close_flow_session", project_id: project.id, session_id: session.session_id }
72
+ });
73
+ }
74
+ }
75
+ const now = context.now();
76
+ const expiredLocks = context.db.all(`SELECT id, lane, worker_id, expires_at FROM lane_locks
77
+ WHERE project_id = ? AND status = 'active' AND expires_at <= ?
78
+ ORDER BY id ASC`, [project.id, now]);
79
+ for (const lock of expiredLocks) {
80
+ pushFinding(findings, actions, "expired_active_lane_lock", "warning", {
81
+ lock_id: lock.id,
82
+ lane: lock.lane,
83
+ worker_id: lock.worker_id,
84
+ expires_at: lock.expires_at,
85
+ action: { kind: "expire_lane_lock", project_id: project.id, lock_id: lock.id }
86
+ });
87
+ }
88
+ const staleWorktrees = context.db.all(`SELECT wr.protocol_id, wr.worktree_path FROM worktree_records wr
89
+ JOIN protocols p ON p.id = wr.protocol_id
90
+ WHERE wr.project_id = ?
91
+ AND wr.status = 'active'
92
+ AND (p.status IN ('closed', 'cancelled') OR p.stage IN ('closed', 'cancelled') OR wr.worktree_path = '')
93
+ ORDER BY wr.protocol_id ASC`, [project.id]);
94
+ for (const worktree of staleWorktrees) {
95
+ pushFinding(findings, actions, "stale_worktree_record", "info", {
96
+ protocol_id: worktree.protocol_id,
97
+ worktree_path: worktree.worktree_path,
98
+ action: {
99
+ kind: "close_worktree_record",
100
+ project_id: project.id,
101
+ protocol_id: worktree.protocol_id,
102
+ worktree_path: worktree.worktree_path
103
+ }
104
+ });
105
+ }
106
+ return {
107
+ ok: true,
108
+ project_root: project.root,
109
+ project_id: project.id,
110
+ findings,
111
+ actions,
112
+ plan: { project_root: project.root, project_id: project.id, actions }
113
+ };
114
+ }
115
+ export function cleanupApply(context, input) {
116
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
117
+ const reason = input.reason.trim();
118
+ if (!reason) {
119
+ throw new AppError("validation", "cleanup apply requires --reason", 2);
120
+ }
121
+ ensureReadableFile(input.planFile);
122
+ const plan = validateCleanupPlan(parseJsonObject(fs.readFileSync(input.planFile, "utf8"), input.planFile), project);
123
+ validateNoDestructiveSurprises(context, plan, input.force);
124
+ const now = context.now();
125
+ const results = [];
126
+ context.db.exec("BEGIN IMMEDIATE");
127
+ try {
128
+ for (const action of plan.actions) {
129
+ results.push(applyAction(context, action, reason, input.force, now));
130
+ }
131
+ const changed = results.filter((result) => result.changed).length;
132
+ if (changed > 0) {
133
+ appendAudit(context, {
134
+ projectId: project.id,
135
+ eventType: "cleanup.applied",
136
+ reason,
137
+ forced: input.force,
138
+ payload: { project_id: project.id, action_count: plan.actions.length, changed, results }
139
+ });
140
+ }
141
+ context.db.exec("COMMIT");
142
+ }
143
+ catch (error) {
144
+ context.db.exec("ROLLBACK");
145
+ throw error;
146
+ }
147
+ return {
148
+ ok: true,
149
+ project_root: project.root,
150
+ requested: plan.actions.length,
151
+ applied: results.filter((result) => result.changed).length,
152
+ changed: results.filter((result) => result.changed).length,
153
+ skipped: results.filter((result) => !result.changed).length,
154
+ results
155
+ };
156
+ }
157
+ function pushFinding(findings, actions, code, severity, details) {
158
+ findings.push({ code, severity, ...details });
159
+ actions.push(details.action);
160
+ }
161
+ function validateCleanupPlan(value, project) {
162
+ if (value.project_root !== project.root || value.project_id !== project.id) {
163
+ throw new AppError("project_mismatch", "cleanup plan does not match --project-root", 1, {
164
+ expected_root: project.root,
165
+ actual_root: value.project_root,
166
+ expected_project_id: project.id,
167
+ actual_project_id: value.project_id
168
+ });
169
+ }
170
+ if (!Array.isArray(value.actions)) {
171
+ throw new AppError("validation", "cleanup plan requires actions array", 2);
172
+ }
173
+ const actions = value.actions.map((entry) => {
174
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
175
+ throw new AppError("validation", "cleanup action must be an object", 2);
176
+ }
177
+ const action = entry;
178
+ if (!actionKinds.has(action.kind)) {
179
+ throw new AppError("validation", "cleanup action kind is not supported", 2, { kind: action.kind });
180
+ }
181
+ if (action.project_id !== project.id) {
182
+ throw new AppError("project_mismatch", "cleanup action project_id does not match project", 1, {
183
+ expected_project_id: project.id,
184
+ actual_project_id: action.project_id
185
+ });
186
+ }
187
+ return action;
188
+ });
189
+ return { project_root: project.root, project_id: project.id, actions };
190
+ }
191
+ function validateNoDestructiveSurprises(context, plan, force) {
192
+ const destructive = plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && dirtyGit(action.worktree_path));
193
+ if (destructive && !force) {
194
+ throw new AppError("worktree_dirty", "cleanup apply refuses dirty worktree actions without --force --reason", 1, {
195
+ path: destructive.worktree_path
196
+ });
197
+ }
198
+ const unsafeWorktree = plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && !safeCleanupWorktreePath(context, action.project_id, action.worktree_path));
199
+ if (unsafeWorktree) {
200
+ throw new AppError("worktree_cleanup_unsafe", "cleanup apply refuses unsafe worktree path", 1, {
201
+ path: unsafeWorktree.worktree_path
202
+ });
203
+ }
204
+ const guardedQueue = plan.actions.find((action) => {
205
+ if (action.kind !== "cancel_queue_job" || !action.protocol_id) {
206
+ return false;
207
+ }
208
+ const job = context.db.get("SELECT status FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [action.project_id, action.protocol_id]);
209
+ return Boolean(job && ["claimed", "merged", "failed"].includes(job.status));
210
+ });
211
+ if (guardedQueue && !force) {
212
+ throw new AppError("cleanup_force_required", "cleanup apply refuses claimed or terminal queue cancellation without --force --reason", 1, {
213
+ protocol_id: guardedQueue.protocol_id
214
+ });
215
+ }
216
+ }
217
+ function applyAction(context, action, reason, force, now) {
218
+ if (action.kind === "repair_protocol_state" && action.protocol_id) {
219
+ const protocol = requireProtocol(context, action.protocol_id);
220
+ const beforePath = protocol.state_path;
221
+ const existed = fs.existsSync(beforePath);
222
+ readProtocolRuntimeState(context, protocol);
223
+ return {
224
+ kind: action.kind,
225
+ changed: !existed || beforePath !== protocol.state_path,
226
+ ...(!existed || beforePath !== protocol.state_path ? {} : { skipped: true, reason: "state_exists" }),
227
+ protocol_id: action.protocol_id
228
+ };
229
+ }
230
+ if (action.kind === "cancel_queue_job" && action.protocol_id) {
231
+ const job = context.db.get("SELECT status FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [action.project_id, action.protocol_id]);
232
+ if (!job) {
233
+ return { kind: action.kind, changed: false, skipped: true, reason: "no_queue_job", protocol_id: action.protocol_id };
234
+ }
235
+ const statuses = force ? "('ready', 'requeued', 'claimed', 'merged', 'failed')" : "('ready', 'requeued', 'claimed')";
236
+ const result = context.db.run(`UPDATE merge_queue SET status = 'cancelled', last_reason = ?, updated_at = ?
237
+ WHERE protocol_id = ? AND project_id = ? AND status IN ${statuses}`, [reason, now, action.protocol_id, action.project_id]);
238
+ if (result.changes !== 1) {
239
+ return { kind: action.kind, changed: false, skipped: true, reason: `already_${job.status}`, protocol_id: action.protocol_id };
240
+ }
241
+ appendAudit(context, {
242
+ projectId: action.project_id,
243
+ protocolId: action.protocol_id,
244
+ eventType: "merge_queue.cancelled",
245
+ reason,
246
+ forced: force,
247
+ payload: { protocol_id: action.protocol_id, from: job.status, source: "cleanup.apply" }
248
+ });
249
+ return { kind: action.kind, changed: true, protocol_id: action.protocol_id };
250
+ }
251
+ if (action.kind === "close_flow_session" && action.session_id) {
252
+ const result = context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
253
+ WHERE project_id = ? AND session_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, action.project_id, action.session_id]);
254
+ if (result.changes !== 1) {
255
+ return { kind: action.kind, changed: false, skipped: true, reason: "already_stopped", session_id: action.session_id };
256
+ }
257
+ appendAudit(context, {
258
+ projectId: action.project_id,
259
+ eventType: "flow_session.stopped",
260
+ reason,
261
+ forced: force,
262
+ payload: { session_id: action.session_id, source: "cleanup.apply" }
263
+ });
264
+ return { kind: action.kind, changed: true, session_id: action.session_id };
265
+ }
266
+ if (action.kind === "expire_lane_lock" && typeof action.lock_id === "number") {
267
+ const result = context.db.run("UPDATE lane_locks SET status = 'expired', reason = ?, updated_at = ? WHERE project_id = ? AND id = ? AND status = 'active'", [reason, now, action.project_id, action.lock_id]);
268
+ return {
269
+ kind: action.kind,
270
+ changed: result.changes === 1,
271
+ ...(result.changes === 1 ? {} : { skipped: true, reason: "already_inactive" }),
272
+ lock_id: action.lock_id
273
+ };
274
+ }
275
+ if (action.kind === "close_worktree_record" && action.protocol_id) {
276
+ const worktreePath = action.worktree_path ?? context.db.get("SELECT worktree_path FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [action.project_id, action.protocol_id])?.worktree_path;
277
+ const status = worktreePath && fs.existsSync(worktreePath) ? "kept" : "removed";
278
+ const result = context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ?
279
+ WHERE project_id = ? AND protocol_id = ? AND status = 'active'`, [status, now, now, action.project_id, action.protocol_id]);
280
+ return {
281
+ kind: action.kind,
282
+ changed: result.changes === 1,
283
+ ...(result.changes === 1 ? { reason: status } : { skipped: true, reason: "already_closed" }),
284
+ protocol_id: action.protocol_id
285
+ };
286
+ }
287
+ return { kind: action.kind, changed: false, skipped: true, reason: "invalid_action" };
288
+ }
289
+ function dirtyGit(worktreePath) {
290
+ if (!fs.existsSync(worktreePath)) {
291
+ return false;
292
+ }
293
+ const result = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { encoding: "utf8" });
294
+ return result.status === 0 && result.stdout.trim().length > 0;
295
+ }
296
+ function safeCleanupWorktreePath(context, projectId, worktreePath) {
297
+ if (!fs.existsSync(worktreePath)) {
298
+ return true;
299
+ }
300
+ const real = fs.realpathSync(worktreePath);
301
+ if (real === process.cwd()) {
302
+ return false;
303
+ }
304
+ const checkoutRoot = projectCheckoutRoot(context.ddFlowHome, projectId);
305
+ if (!fs.existsSync(checkoutRoot)) {
306
+ return true;
307
+ }
308
+ const root = fs.realpathSync(checkoutRoot);
309
+ return real === root || real.startsWith(`${root}${path.sep}`);
310
+ }