@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.
- package/README.md +274 -0
- package/dist/cli/help.js +308 -0
- package/dist/cli/run-cli.js +945 -0
- package/dist/cli.js +4 -0
- package/dist/domain/contracts.js +57 -0
- package/dist/domain/entity-ids.js +47 -0
- package/dist/domain/flow-contract.js +233 -0
- package/dist/domain/validation.js +91 -0
- package/dist/protocol/local-files.js +141 -0
- package/dist/runtime/context.js +11 -0
- package/dist/schemas/code-stage-report.schema.json +181 -0
- package/dist/schemas/flow-run-index.schema.json +129 -0
- package/dist/schemas/mb-upgrade-review-data.schema.json +813 -0
- package/dist/schemas/memorybank-permissions-preflight.schema.json +154 -0
- package/dist/schemas/merge-stage-report.schema.json +135 -0
- package/dist/services/audit.js +19 -0
- package/dist/services/cleanup.js +310 -0
- package/dist/services/config.js +143 -0
- package/dist/services/dashboard.js +436 -0
- package/dist/services/hooks.js +929 -0
- package/dist/services/lanes.js +327 -0
- package/dist/services/memory-permissions.js +344 -0
- package/dist/services/merge-queue.js +333 -0
- package/dist/services/plans.js +149 -0
- package/dist/services/projects.js +286 -0
- package/dist/services/protocols.js +606 -0
- package/dist/services/runs.js +359 -0
- package/dist/services/schema-validation.js +185 -0
- package/dist/services/sessions.js +365 -0
- package/dist/services/worktrees.js +204 -0
- package/dist/shared/errors.js +14 -0
- package/dist/shared/json.js +17 -0
- package/dist/storage/database.js +325 -0
- package/dist/storage/paths.js +56 -0
- 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
|
+
}
|