@deksden-com/dd-flow-cli 0.9.0-beta.75 → 0.9.0-beta.79
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/dist/build-info.json +6 -6
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +1 -1
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1015 -423
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
- package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
- package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +49 -8
- package/dist/services/cli-operation-classifier.js +12 -24
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +2 -2
- package/dist/services/dashboard.js +94 -31
- package/dist/services/engines.js +75 -61
- package/dist/services/eval-snapshots.js +138 -38
- package/dist/services/execution-policy.js +5 -12
- package/dist/services/hooks.js +226 -203
- package/dist/services/lanes.js +60 -52
- package/dist/services/lifecycle-command.js +13 -1
- package/dist/services/lifecycle-invocations.js +285 -94
- package/dist/services/managed-processes.js +158 -18
- package/dist/services/merge-queue.js +173 -98
- package/dist/services/migrations.js +13 -8
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +11 -2
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +54 -19
- package/dist/services/recovery-observation-budget.js +1 -1
- package/dist/services/run-controller-adapter.js +31 -20
- package/dist/services/run-controller-state.js +5 -0
- package/dist/services/run-controller.js +102 -56
- package/dist/services/run-fork.js +36 -22
- package/dist/services/run-recovery.js +32 -17
- package/dist/services/runs.js +110 -38
- package/dist/services/runtime-budget.js +120 -48
- package/dist/services/runtime-scope-capture.js +2 -2
- package/dist/services/runtime-scope-control.js +2 -2
- package/dist/services/runtime-scope-resume.js +34 -12
- package/dist/services/runtime-scope-worker.js +49 -16
- package/dist/services/runtime-service.js +8 -7
- package/dist/services/schema-validation.js +9 -7
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +42 -16
- package/dist/services/stage-lifecycle.js +82 -100
- package/dist/services/stage-pause.js +54 -24
- package/dist/services/vnext-code-review.js +82 -32
- package/dist/services/vnext-code.js +78 -45
- package/dist/services/vnext-fanout.js +13 -12
- package/dist/services/vnext-merge.js +68 -35
- package/dist/services/vnext-plan-review.js +84 -41
- package/dist/services/vnext-plan.js +91 -70
- package/dist/services/vnext-protocolize.js +116 -50
- package/dist/services/vnext-specify.js +70 -57
- package/dist/services/work-registry.js +195 -92
- package/dist/services/worktrees.js +85 -35
- package/dist/storage/database.js +104 -10
- package/dist/storage/paths.js +17 -4
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process";
|
|
|
4
4
|
import { flowContractForState } from "../domain/flow-contract.js";
|
|
5
5
|
import { requireStage } from "../domain/validation.js";
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
|
-
import { projectCheckoutRoot, resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
+
import { canonicalPath, projectCheckoutRoot, resolveProjectRoot } from "../storage/paths.js";
|
|
8
8
|
import { appendAudit } from "./audit.js";
|
|
9
9
|
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
10
10
|
import { requireProjectByRoot } from "./projects.js";
|
|
@@ -28,7 +28,7 @@ export function planWorktree(context, input) {
|
|
|
28
28
|
worktrunk: inspectManagedWorktrunk(context)
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
|
-
export function
|
|
31
|
+
export function prepareWorktreeCreate(context, input) {
|
|
32
32
|
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
33
33
|
const existing = activeWorktreeRecord(context, protocol.project_id, protocol.id);
|
|
34
34
|
if (existing) {
|
|
@@ -37,17 +37,56 @@ export function createWorktreeRecord(context, input) {
|
|
|
37
37
|
worktree_path: existing.worktree_path
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
return prepareManagedWorktree({ ...input, projectRoot: protocol.project_root });
|
|
41
|
+
}
|
|
42
|
+
export function createWorktreeRecord(context, input, prepared = prepareWorktreeCreate(context, input)) {
|
|
43
|
+
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
44
|
+
if (activeWorktreeRecord(context, protocol.project_id, protocol.id))
|
|
45
|
+
throw new AppError("worktree_already_exists", "Protocol already has an active worktree record", 1);
|
|
46
|
+
const created = createManagedWorktree(context, prepared, prepared);
|
|
47
|
+
recordManagedWorktree(context, { protocolId: protocol.id, projectRoot: protocol.project_root, branch: prepared.branch, base: prepared.base, created });
|
|
42
48
|
return { ok: true, worktree: activeWorktreeRecord(context, protocol.project_id, protocol.id) };
|
|
43
49
|
}
|
|
44
|
-
|
|
45
|
-
export function createManagedWorktree(context, input) {
|
|
50
|
+
export function prepareManagedWorktree(input) {
|
|
46
51
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
52
|
+
const invalid = (message) => { throw new AppError("validation", message, 2, { phase: "prepare", effect: "no_effect", recoverable: true }); };
|
|
53
|
+
for (const key of ["branch", "base", "path"])
|
|
54
|
+
if (typeof input[key] !== "string" || !input[key].trim() || input[key].includes("\0"))
|
|
55
|
+
invalid(`--${key} must be a non-empty value without NUL`);
|
|
56
|
+
const git = (args) => {
|
|
57
|
+
const result = spawnSync("git", ["-C", projectRoot, ...args], { encoding: "utf8", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } });
|
|
58
|
+
if (result.error)
|
|
59
|
+
throw result.error;
|
|
60
|
+
return result;
|
|
61
|
+
};
|
|
62
|
+
const branch = git(["check-ref-format", "--branch", input.branch]);
|
|
63
|
+
if (input.branch.startsWith("-") || branch.status !== 0 || branch.stdout.trim() !== input.branch)
|
|
64
|
+
invalid("--branch must be a literal valid Git branch name");
|
|
65
|
+
const base = git(["rev-parse", "--verify", "--end-of-options", `${input.base}^{commit}`]);
|
|
66
|
+
if (base.status !== 0)
|
|
67
|
+
invalid("--base must resolve to an existing Git commit");
|
|
68
|
+
if (git(["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`]).status === 0)
|
|
69
|
+
invalid("--branch already exists; choose a new feature branch");
|
|
70
|
+
const destination = canonicalPath(input.path, false);
|
|
71
|
+
if (isTrackedProjectPath(canonicalPath(projectRoot), destination))
|
|
72
|
+
invalid("--path must be outside the project checkout");
|
|
73
|
+
if (fs.existsSync(destination) && (!fs.statSync(destination).isDirectory() || fs.readdirSync(destination).length))
|
|
74
|
+
invalid("--path must be an absent or empty directory");
|
|
75
|
+
let parent = path.dirname(destination);
|
|
76
|
+
while (!fs.existsSync(parent) && path.dirname(parent) !== parent)
|
|
77
|
+
parent = path.dirname(parent);
|
|
78
|
+
if (!fs.statSync(parent).isDirectory())
|
|
79
|
+
invalid("--path has a parent that is not a directory");
|
|
80
|
+
fs.accessSync(parent, fs.constants.W_OK | fs.constants.X_OK);
|
|
81
|
+
return { projectRoot, branch: input.branch, base: base.stdout.trim(), path: destination };
|
|
82
|
+
}
|
|
83
|
+
/** Creates the checkout before a PRT exists. PROTOCOLIZE materializes the PRT only inside this checkout. */
|
|
84
|
+
export function createManagedWorktree(context, input, prepared = prepareManagedWorktree(input)) {
|
|
85
|
+
const { projectRoot } = prepared;
|
|
47
86
|
const worktrunk = ensureManagedWorktrunk(context);
|
|
48
87
|
// Worktrunk owns creation; the CLI supplies its project-scoped service path
|
|
49
88
|
// as a one-shot config override rather than relying on a developer's user config.
|
|
50
|
-
const command = [worktrunk.bin, "-C", projectRoot, "--config-set", `worktree-path = ${JSON.stringify(
|
|
89
|
+
const command = [worktrunk.bin, "-C", projectRoot, "--config-set", `worktree-path = ${JSON.stringify(prepared.path)}`, "switch", "--create", prepared.branch, "--base", prepared.base, "--no-cd", "--format=json", "-y"];
|
|
51
90
|
const result = runExternal(command);
|
|
52
91
|
if (result.exit_code !== 0)
|
|
53
92
|
throw new AppError("worktrunk_command_unavailable", "Worktrunk switch failed", 1, { command: sanitizeCommand(command), result });
|
|
@@ -76,14 +115,29 @@ export function recordManagedWorktree(context, input) {
|
|
|
76
115
|
appendAudit(context, { protocolId: protocol.id, projectId: protocol.project_id, eventType: "worktree.created", payload: { protocol_id: protocol.id, feature_branch: input.branch, base: input.base, path: input.created.worktreePath } });
|
|
77
116
|
}
|
|
78
117
|
/** Bootstrap a just-created RUN workspace before a durable PRT exists. */
|
|
79
|
-
export function
|
|
80
|
-
const projectRoot = resolveProjectRoot(
|
|
81
|
-
const worktreePath = path.resolve(input.worktreePath);
|
|
118
|
+
export function prepareManagedWorktreeBootstrapSources(root) {
|
|
119
|
+
const projectRoot = resolveProjectRoot(root);
|
|
82
120
|
const includeFile = path.join(projectRoot, ".worktreeinclude");
|
|
83
121
|
const configFile = path.join(projectRoot, ".config", "wt.toml");
|
|
84
122
|
if (!fs.existsSync(includeFile) || !fs.existsSync(configFile)) {
|
|
85
123
|
throw new AppError("bootstrap_unavailable", "Project-owned Worktrunk bootstrap files are required", 1, { include_file: includeFile, config_file: configFile });
|
|
86
124
|
}
|
|
125
|
+
for (const file of [includeFile, configFile]) {
|
|
126
|
+
if (!fs.statSync(file).isFile())
|
|
127
|
+
throw new AppError("bootstrap_unavailable", "Project-owned bootstrap inputs must be regular files", 1, { file });
|
|
128
|
+
fs.accessSync(file, fs.constants.R_OK);
|
|
129
|
+
}
|
|
130
|
+
return projectRoot;
|
|
131
|
+
}
|
|
132
|
+
export function prepareManagedWorktreeBootstrap(input) {
|
|
133
|
+
const projectRoot = prepareManagedWorktreeBootstrapSources(input.projectRoot);
|
|
134
|
+
const worktreePath = path.resolve(input.worktreePath);
|
|
135
|
+
if (!fs.statSync(worktreePath).isDirectory())
|
|
136
|
+
throw new AppError("bootstrap_unavailable", "Bootstrap worktree must be a directory", 1, { worktree_path: worktreePath });
|
|
137
|
+
return { projectRoot, worktreePath };
|
|
138
|
+
}
|
|
139
|
+
export function bootstrapManagedWorktree(context, input, prepared = prepareManagedWorktreeBootstrap(input)) {
|
|
140
|
+
const { worktreePath } = prepared;
|
|
87
141
|
const worktrunk = ensureManagedWorktrunk(context);
|
|
88
142
|
const commands = [
|
|
89
143
|
[worktrunk.bin, "-C", worktreePath, "step", "copy-ignored", "--require-include"],
|
|
@@ -103,38 +157,30 @@ export function getWorktreeStatus(context, input) {
|
|
|
103
157
|
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
104
158
|
return { ok: true, protocol_id: protocol.id, worktree: worktreeRecord(context, protocol.project_id, protocol.id), worktrunk: inspectManagedWorktrunk(context) };
|
|
105
159
|
}
|
|
106
|
-
export function
|
|
160
|
+
export function prepareWorktreeBootstrap(context, input) {
|
|
107
161
|
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
108
162
|
const record = activeWorktreeRecord(context, protocol.project_id, protocol.id);
|
|
109
163
|
if (!record) {
|
|
110
164
|
throw new AppError("not_found", `Worktree record is not active for protocol: ${protocol.id}`, 1);
|
|
111
165
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
166
|
+
return { protocol, record, managed: prepareManagedWorktreeBootstrap({ projectRoot: protocol.project_root, worktreePath: record.worktree_path }) };
|
|
167
|
+
}
|
|
168
|
+
export function bootstrapWorktree(context, input, prepared = prepareWorktreeBootstrap(context, input)) {
|
|
169
|
+
const { protocol, record } = prepared;
|
|
170
|
+
const active = activeWorktreeRecord(context, protocol.project_id, protocol.id);
|
|
171
|
+
if (!active || active.worktree_path !== record.worktree_path)
|
|
172
|
+
throw new AppError("worktree_active", "Worktree changed after bootstrap preparation", 1);
|
|
173
|
+
let result;
|
|
174
|
+
try {
|
|
175
|
+
result = bootstrapManagedWorktree(context, prepared.managed, prepared.managed);
|
|
120
176
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
];
|
|
126
|
-
const results = [];
|
|
127
|
-
for (const command of commands) {
|
|
128
|
-
const result = runExternal(command);
|
|
129
|
-
results.push({ command: sanitizeCommand(command), ...result });
|
|
130
|
-
if (result.exit_code !== 0) {
|
|
131
|
-
const failed = { command: sanitizeCommand(command), result, results };
|
|
132
|
-
markBootstrapFailed(context, protocol, record, failed);
|
|
133
|
-
throw new AppError("bootstrap_failed", "Project-owned Worktrunk bootstrap failed", 1, failed);
|
|
134
|
-
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error instanceof AppError && error.code === "bootstrap_failed")
|
|
179
|
+
markBootstrapFailed(context, protocol, record, error.details);
|
|
180
|
+
throw error;
|
|
135
181
|
}
|
|
136
182
|
const now = context.now();
|
|
137
|
-
const
|
|
183
|
+
const results = result.commands;
|
|
138
184
|
context.db.run(`UPDATE worktree_records
|
|
139
185
|
SET bootstrap_status = 'succeeded', last_command_result_json = ?, updated_at = ?
|
|
140
186
|
WHERE project_id = ? AND protocol_id = ?`, [JSON.stringify(result), now, protocol.project_id, protocol.id]);
|
|
@@ -155,7 +201,7 @@ export function bootstrapWorktree(context, input) {
|
|
|
155
201
|
});
|
|
156
202
|
return { ok: true, worktree: worktreeRecord(context, protocol.project_id, protocol.id) };
|
|
157
203
|
}
|
|
158
|
-
export function
|
|
204
|
+
export function prepareWorktreeClose(context, input) {
|
|
159
205
|
if (!["keep", "remove"].includes(input.mode)) {
|
|
160
206
|
throw new AppError("validation", "--mode must be keep or remove", 2);
|
|
161
207
|
}
|
|
@@ -171,6 +217,10 @@ export function closeWorktree(context, input) {
|
|
|
171
217
|
stage: state.stage
|
|
172
218
|
});
|
|
173
219
|
}
|
|
220
|
+
return { protocol, record };
|
|
221
|
+
}
|
|
222
|
+
export function closeWorktree(context, input) {
|
|
223
|
+
const { protocol, record } = prepareWorktreeClose(context, input);
|
|
174
224
|
const now = context.now();
|
|
175
225
|
const nextStatus = input.mode === "keep" ? "kept" : fs.existsSync(record.worktree_path) ? "kept" : "removed";
|
|
176
226
|
context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ? WHERE project_id = ? AND protocol_id = ?`, [nextStatus, now, now, protocol.project_id, protocol.id]);
|
package/dist/storage/database.js
CHANGED
|
@@ -8,6 +8,20 @@ import { executeStoreSchema, readStoreWriterContract, registerStoreWriter } from
|
|
|
8
8
|
const require = createRequire(import.meta.url);
|
|
9
9
|
const { DatabaseSync } = require("node:sqlite");
|
|
10
10
|
const resourceDatabases = new Map();
|
|
11
|
+
/** Invocation authority is prepared once with the runtime schema. Native
|
|
12
|
+
* hooks must only verify this table; they never create it on the hot path. */
|
|
13
|
+
export function ensureLifecycleInvocationStorage(db) {
|
|
14
|
+
db.exec(`
|
|
15
|
+
CREATE TABLE IF NOT EXISTS lifecycle_invocations (
|
|
16
|
+
id TEXT PRIMARY KEY, scope_json TEXT NOT NULL, command TEXT NOT NULL,
|
|
17
|
+
fingerprint TEXT NOT NULL, status TEXT NOT NULL,
|
|
18
|
+
event_key TEXT UNIQUE, identity_json TEXT, deadline INTEGER, outcome_json TEXT,
|
|
19
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_lifecycle_invocations_scope
|
|
22
|
+
ON lifecycle_invocations(scope_json, fingerprint, created_at);
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
11
25
|
/** RUN authority stays in its existing store; process leases stay in runtime.sqlite. */
|
|
12
26
|
export function ensureRunControlStorage(db) {
|
|
13
27
|
db.execSchema(`
|
|
@@ -232,6 +246,8 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
232
246
|
if (mode === "read_existing" && !exists) {
|
|
233
247
|
return emptyReadOnlyDatabase(dbPath);
|
|
234
248
|
}
|
|
249
|
+
if (mode === "hook" && !exists)
|
|
250
|
+
throw new AppError("hook_storage_unprepared", "Prepare the runtime store before starting native hooks", 1);
|
|
235
251
|
if (!exists || mode === "migrate")
|
|
236
252
|
ensureDir(ddFlowHome);
|
|
237
253
|
const db = mode === "read_existing" ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
|
|
@@ -256,14 +272,19 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
256
272
|
// Higher-level Memory Bank migrations remain explicit in services/migrations.
|
|
257
273
|
if (mode !== "read_existing") {
|
|
258
274
|
try {
|
|
259
|
-
const initialize = () => { migrate(db); ensureRecoveryStorage(db); };
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
275
|
+
const initialize = () => { migrate(db); ensureRecoveryStorage(db); ensureLifecycleInvocationStorage(db); };
|
|
276
|
+
if (mode !== "hook") {
|
|
277
|
+
const fresh = !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
|
|
278
|
+
if (fresh || readStoreWriterContract(db) !== null)
|
|
279
|
+
executeStoreSchema(db, initialize, fresh);
|
|
280
|
+
else
|
|
281
|
+
initialize();
|
|
282
|
+
// This migration owns a VACUUM backup and a separate data transaction.
|
|
283
|
+
migrateSessionStorageKeys(db, dbPath);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
assertHookStoreReady(db);
|
|
287
|
+
}
|
|
267
288
|
}
|
|
268
289
|
catch (error) {
|
|
269
290
|
db.close();
|
|
@@ -275,15 +296,88 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
275
296
|
db.exec("PRAGMA foreign_keys = ON");
|
|
276
297
|
return databaseFacade(dbPath, mode !== "read_existing", db);
|
|
277
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Hook ingress is a hot, synchronous path. It may write receipts, but it
|
|
301
|
+
* must never perform schema migration or VACUUM while the native host waits.
|
|
302
|
+
* Startup/migration owns this contract; a missing table is an explicit
|
|
303
|
+
* preparation error instead of an implicit migration race.
|
|
304
|
+
*/
|
|
305
|
+
function assertHookStoreReady(db) {
|
|
306
|
+
const required = ["projects", "hook_events", "codex_session_bindings", "sessions", "flow_session_segments", "lifecycle_invocations"];
|
|
307
|
+
const missing = required.filter((table) => !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table));
|
|
308
|
+
if (missing.length)
|
|
309
|
+
throw new AppError("hook_storage_unprepared", "Managed store is not prepared for synchronous hook admission", 1, { missing_tables: missing, next_action: "Open the runtime with a normal dd-flow command and complete migration before starting the native Session." });
|
|
310
|
+
// Table presence alone does not establish compatibility with an older home.
|
|
311
|
+
const receiptColumns = {
|
|
312
|
+
hook_events: "id, project_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, event_name, tool_name, event_key, cwd, match_key, transcript_path, status, sanitized_summary, outcome_json, created_at",
|
|
313
|
+
lifecycle_invocations: "id, scope_json, command, fingerprint, status, event_key, identity_json, deadline, outcome_json, created_at, updated_at",
|
|
314
|
+
codex_session_bindings: "session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at"
|
|
315
|
+
};
|
|
316
|
+
for (const [table, columns] of Object.entries(receiptColumns)) {
|
|
317
|
+
try {
|
|
318
|
+
db.prepare(`SELECT ${columns} FROM ${table} LIMIT 0`);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
throw new AppError("hook_storage_unprepared", "Managed receipt schema needs preparation before native hooks start", 1, { table });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
278
325
|
/**
|
|
279
326
|
* Opens the small host-wide resource registry. It is intentionally separate
|
|
280
327
|
* from a RUN database: a process or a port can outlive the CLI client that
|
|
281
328
|
* created it, while a RUN database belongs to one flow home.
|
|
282
329
|
*/
|
|
283
|
-
export function
|
|
284
|
-
|
|
330
|
+
export function withResourceReadSnapshot(resourceHome, read) {
|
|
331
|
+
const dbPath = path.join(resourceHome, "runtime.sqlite");
|
|
332
|
+
const cached = resourceDatabases.get(dbPath);
|
|
333
|
+
if (!cached && !fs.existsSync(dbPath))
|
|
334
|
+
return read(emptyReadOnlyDatabase(dbPath));
|
|
335
|
+
const db = cached ?? new DatabaseSync(dbPath, { readOnly: true });
|
|
336
|
+
try {
|
|
337
|
+
if (!cached)
|
|
338
|
+
db.exec("PRAGMA busy_timeout = 4000; PRAGMA query_only = ON");
|
|
339
|
+
const reader = cached
|
|
340
|
+
? { ...emptyReadOnlyDatabase(dbPath), get: cached.get, all: cached.all }
|
|
341
|
+
: databaseFacade(dbPath, false, db);
|
|
342
|
+
// SAVEPOINT also composes with an existing owner's transaction. The
|
|
343
|
+
// callback sees its uncommitted state but never receives write capability.
|
|
344
|
+
db.exec("SAVEPOINT resource_read_snapshot");
|
|
345
|
+
try {
|
|
346
|
+
const result = read(reader);
|
|
347
|
+
db.exec("RELEASE resource_read_snapshot");
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
db.exec("ROLLBACK TO resource_read_snapshot");
|
|
352
|
+
db.exec("RELEASE resource_read_snapshot");
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
if (!cached)
|
|
358
|
+
db.close?.();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
export function getResourceDatabase(resourceHome, mode = "initialize") {
|
|
285
362
|
const dbPath = path.join(resourceHome, "runtime.sqlite");
|
|
286
363
|
const cached = resourceDatabases.get(dbPath);
|
|
364
|
+
if (mode === "read_existing") {
|
|
365
|
+
// Reuse a current transaction's view without exposing its write capability.
|
|
366
|
+
if (cached)
|
|
367
|
+
return { ...emptyReadOnlyDatabase(dbPath), get: cached.get, all: cached.all };
|
|
368
|
+
if (!fs.existsSync(dbPath))
|
|
369
|
+
return emptyReadOnlyDatabase(dbPath);
|
|
370
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
371
|
+
try {
|
|
372
|
+
db.exec("PRAGMA busy_timeout = 4000; PRAGMA query_only = ON");
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
db.close();
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
return databaseFacade(dbPath, false, db);
|
|
379
|
+
}
|
|
380
|
+
ensureDir(resourceHome);
|
|
287
381
|
if (cached)
|
|
288
382
|
return cached;
|
|
289
383
|
const db = new DatabaseSync(dbPath);
|
package/dist/storage/paths.js
CHANGED
|
@@ -15,6 +15,8 @@ export function resolveProjectRoot(root) {
|
|
|
15
15
|
if (!fs.existsSync(absolute)) {
|
|
16
16
|
throw new AppError("not_found", `Project root does not exist: ${absolute}`, 1);
|
|
17
17
|
}
|
|
18
|
+
if (!fs.statSync(absolute).isDirectory())
|
|
19
|
+
throw new AppError("validation", "Project root must be a directory", 2, { project_root: absolute });
|
|
18
20
|
return fs.realpathSync(absolute);
|
|
19
21
|
}
|
|
20
22
|
/** Resolve an existing path to its filesystem identity, or normalize a future path. */
|
|
@@ -24,10 +26,16 @@ export function canonicalPath(value, mustExist = true) {
|
|
|
24
26
|
return fs.realpathSync(absolute);
|
|
25
27
|
if (mustExist)
|
|
26
28
|
throw new AppError("not_found", `Path does not exist: ${absolute}`, 1, { path: absolute });
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const missing = [];
|
|
30
|
+
let parent = absolute;
|
|
31
|
+
while (!fs.existsSync(parent)) {
|
|
32
|
+
missing.unshift(path.basename(parent));
|
|
33
|
+
const next = path.dirname(parent);
|
|
34
|
+
if (next === parent)
|
|
35
|
+
return absolute;
|
|
36
|
+
parent = next;
|
|
37
|
+
}
|
|
38
|
+
return path.join(fs.realpathSync(parent), ...missing);
|
|
31
39
|
}
|
|
32
40
|
export function assertPathWithin(root, candidate, label = "path") {
|
|
33
41
|
const canonicalRoot = canonicalPath(root);
|
|
@@ -100,6 +108,11 @@ export function stateJsonPath(projectRoot, protocolId) {
|
|
|
100
108
|
export function planJsonPath(projectRoot, protocolId) {
|
|
101
109
|
return path.join(protocolDir(projectRoot, protocolId), "plan.json");
|
|
102
110
|
}
|
|
111
|
+
/** Project canonical plan takes precedence over a retained workspace location. */
|
|
112
|
+
export function resolveProtocolPlanPath(projectRoot, protocolId, recordedPath) {
|
|
113
|
+
const canonical = planJsonPath(projectRoot, protocolId);
|
|
114
|
+
return fs.existsSync(canonical) ? canonical : recordedPath;
|
|
115
|
+
}
|
|
103
116
|
export function projectHome(ddFlowHome, projectId) {
|
|
104
117
|
return path.join(ddFlowHome, "projects", projectId);
|
|
105
118
|
}
|