@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.110
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 +520 -0
- package/README.md +68 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +305 -0
- package/dist/cli/help.js +89 -13
- package/dist/cli/hook-ingress.js +77 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/native-hook-ingress.js +48 -0
- package/dist/cli/run-cli.js +1465 -384
- package/dist/cli.js +8 -2
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +26 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +500 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +149 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +388 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +645 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +982 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +327 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +108 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +71 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +2 -2
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-work-batch.schema.json +3 -3
- package/dist/schemas/code-work-result.schema.json +3 -3
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +132 -39
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +589 -106
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +173 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +994 -97
- package/dist/services/execution-policy.js +181 -0
- package/dist/services/external-work-launch.js +166 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +702 -327
- package/dist/services/lanes.js +78 -65
- package/dist/services/lifecycle-command.js +113 -18
- package/dist/services/lifecycle-contract.js +30 -0
- package/dist/services/lifecycle-invocations.js +1379 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +302 -34
- package/dist/services/merge-queue.js +228 -149
- package/dist/services/merge-server.js +66 -28
- package/dist/services/migrations.js +37 -16
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +26 -11
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +20 -15
- package/dist/services/prompts.js +25 -16
- package/dist/services/protocols.js +160 -86
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +142 -0
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/review-copy.js +136 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +351 -0
- package/dist/services/run-control.js +888 -0
- package/dist/services/run-controller-adapter.js +231 -0
- package/dist/services/run-controller-capture.js +167 -0
- package/dist/services/run-controller-process.js +180 -0
- package/dist/services/run-controller-recovery.js +221 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +798 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +142 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-projection.js +22 -64
- package/dist/services/run-recovery-runtime.js +80 -0
- package/dist/services/run-recovery.js +345 -0
- package/dist/services/runs.js +536 -181
- package/dist/services/runtime-budget.js +413 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +52 -0
- package/dist/services/runtime-scope-control.js +460 -0
- package/dist/services/runtime-scope-resume.js +577 -0
- package/dist/services/runtime-scope-stop.js +159 -0
- package/dist/services/runtime-scope-worker.js +242 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/scope-writer-cleanup.js +26 -0
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +50 -19
- package/dist/services/stage-lifecycle.js +100 -103
- package/dist/services/stage-pause.js +88 -75
- package/dist/services/stage-report-renderer.js +116 -6
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +35 -98
- package/dist/services/vnext-code-review.js +197 -84
- package/dist/services/vnext-code.js +278 -251
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +172 -17
- package/dist/services/vnext-merge.js +345 -111
- package/dist/services/vnext-plan-review.js +171 -111
- package/dist/services/vnext-plan.js +180 -87
- package/dist/services/vnext-protocolize.js +320 -74
- package/dist/services/vnext-specify.js +115 -77
- package/dist/services/work-registry.js +1001 -187
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/workspace-files.js +83 -0
- package/dist/services/worktrees.js +88 -36
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +692 -35
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/paths.js +17 -4
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +85 -0
- package/package.json +24 -13
- package/tools/repair-paused-run-status.mjs +84 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
6
|
+
import { codeExecutionEnvironment, reconcileWorkspaceOwnership, runCheck, workspaceOperationId } from "./code-checks.js";
|
|
7
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedOwnerIsAlive, managedProcessStatus, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
|
|
8
|
+
/** CODE and MERGE prepare their own checkout through the same managed executor.
|
|
9
|
+
* Bootstrap is a project operation, not an alias classified by the engine as a test. */
|
|
10
|
+
export async function runWorkspaceBootstrap(context, input) {
|
|
11
|
+
reconcileWorkspaceOwnership(context, input.workspaceRoot);
|
|
12
|
+
const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
|
|
13
|
+
const operationId = workspaceOperationId(input.workspaceRoot);
|
|
14
|
+
const identity = crypto.createHash("sha256").update(JSON.stringify([input.projectId, input.runId, generation, path.resolve(input.workspaceRoot), input.command])).digest("hex");
|
|
15
|
+
const pending = managedProcessStatus(context).find(item => item.operation_id === operationId && ["starting", "running", "stopping"].includes(item.state));
|
|
16
|
+
if (pending) {
|
|
17
|
+
if (pending.kind !== "workspace-bootstrap")
|
|
18
|
+
throw new AppError("bootstrap_in_progress", "A check currently owns this workspace; wait for it before bootstrapping", 2, { process_id: pending.id, stdout_path: pending.stdout_path, stderr_path: pending.stderr_path });
|
|
19
|
+
if (processTreeIsAlive(pending) || (pending.state === "starting" && managedOwnerIsAlive(pending) !== false)) {
|
|
20
|
+
throw new AppError("bootstrap_in_progress", "Workspace preparation is still running; inspect its logs and wait for the existing process, do not launch another copy", 2, { process_id: pending.id, stdout_path: pending.stdout_path, stderr_path: pending.stderr_path });
|
|
21
|
+
}
|
|
22
|
+
let pendingIdentity = null;
|
|
23
|
+
try {
|
|
24
|
+
const metadata = JSON.parse(pending.metadata_json);
|
|
25
|
+
pendingIdentity = typeof metadata.bootstrap_identity === "string" ? metadata.bootstrap_identity : null;
|
|
26
|
+
}
|
|
27
|
+
catch { /* Legacy records have no usable identity. */ }
|
|
28
|
+
if (pendingIdentity !== identity) {
|
|
29
|
+
finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: "failed", reason: "bootstrap_identity_superseded" });
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const completionPath = path.join(path.dirname(pending.stdout_path), "completion.json");
|
|
33
|
+
let completion = null;
|
|
34
|
+
try {
|
|
35
|
+
completion = JSON.parse(fs.readFileSync(completionPath, "utf8"));
|
|
36
|
+
}
|
|
37
|
+
catch { /* Interrupted shell has no terminal marker. */ }
|
|
38
|
+
const exitCode = typeof completion?.exit_code === "number" ? completion.exit_code : null;
|
|
39
|
+
const error = exitCode === null ? "bootstrap ended without a completion marker; inspect retained logs before retrying" : null;
|
|
40
|
+
finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: exitCode === 0 ? "stopped" : "failed", reason: error ?? "bootstrap_completion_reconciled" });
|
|
41
|
+
assertRunMutationAllowed(context, input.projectId, input.runId, generation);
|
|
42
|
+
return result(pending.id, pending.stdout_path, pending.stderr_path, completionPath, exitCode, error);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const directory = path.join(input.runHome, input.artifactDir, `bootstrap-${crypto.randomUUID()}`);
|
|
46
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
47
|
+
const stdoutPath = path.join(directory, "stdout.log"), stderrPath = path.join(directory, "stderr.log"), completionPath = path.join(directory, "completion.json");
|
|
48
|
+
const managed = registerManagedProcess(context, { kind: "workspace-bootstrap", ownerId: `${operationId}:${identity}`, ownerPid: process.pid, operationId, uniqueActiveOperation: true, projectId: input.projectId, runId: input.runId, stdoutPath, stderrPath, metadata: { command: input.command, workspace_root: input.workspaceRoot, bootstrap_identity: identity } });
|
|
49
|
+
const stdout = fs.openSync(stdoutPath, "a"), stderr = fs.openSync(stderrPath, "a");
|
|
50
|
+
try {
|
|
51
|
+
try {
|
|
52
|
+
input.progress?.(`workspace bootstrap preparing: ${input.command}; logs: ${directory}`);
|
|
53
|
+
assertRunMutationAllowed(context, input.projectId, input.runId, generation);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: "failed", reason: "dispatch_fenced_before_spawn" });
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
const completed = await runCheck(input.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_CHECK_COMPLETION_FILE: completionPath }, stdout, stderr, elapsed => input.progress?.(`workspace bootstrap still running (${elapsed}s); logs: ${directory}; next report in 15s`), pid => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
|
|
60
|
+
const current = managedProcessStatus(context).find(item => item.id === managed.id);
|
|
61
|
+
if (processTreeIsAlive(current))
|
|
62
|
+
throw new AppError("bootstrap_tree_not_settled", "Bootstrap shell exited but still owns live processes; resources were retained for inspection", 2, { process_id: managed.id, stdout_path: stdoutPath, stderr_path: stderrPath });
|
|
63
|
+
assertRunMutationAllowed(context, input.projectId, input.runId, generation);
|
|
64
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: completed.exitCode === 0 && !completed.error ? "stopped" : "failed", reason: completed.error });
|
|
65
|
+
return result(managed.id, stdoutPath, stderrPath, completionPath, completed.exitCode, completed.error);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
fs.closeSync(stdout);
|
|
69
|
+
fs.closeSync(stderr);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function result(id, stdout, stderr, completion, exitCode, error) {
|
|
73
|
+
// Full logs remain on disk; keep the returned prompt bounded.
|
|
74
|
+
const excerpt = (file) => fs.existsSync(file) ? fs.readFileSync(file, "utf8").slice(-64_000) : "";
|
|
75
|
+
return { exit_code: exitCode, stdout: excerpt(stdout), stderr: excerpt(stderr), error, process_id: id, stdout_path: stdout, stderr_path: stderr, completion_path: completion };
|
|
76
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { agyWorkspaceHooks } from "../harness-runtime/lib/agy-hooks.mjs";
|
|
7
|
+
/** Local environment/credential files are never an exported reviewer or EVAL input. */
|
|
8
|
+
export function localEnvironmentAllowed(source) {
|
|
9
|
+
const name = path.basename(source);
|
|
10
|
+
return name === ".env.example" || (name !== ".env" && !name.startsWith(".env."));
|
|
11
|
+
}
|
|
12
|
+
/** A damaged/inaccessible repository is not an intentional gitless workspace. */
|
|
13
|
+
export function isGitlessWorkspace(root, result) {
|
|
14
|
+
if (result.error || result.status !== 128 || !/not a git repository|outside a repository/i.test(result.stderr ?? ""))
|
|
15
|
+
return false;
|
|
16
|
+
for (let directory = path.resolve(root);; directory = path.dirname(directory)) {
|
|
17
|
+
try {
|
|
18
|
+
fs.lstatSync(path.join(directory, ".git"));
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code !== "ENOENT")
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (path.dirname(directory) === directory)
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function workspaceGitFiles(root, args) {
|
|
30
|
+
const result = spawnSync("git", ["ls-files", "-z", ...args], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
31
|
+
if (isGitlessWorkspace(root, result))
|
|
32
|
+
return null;
|
|
33
|
+
if (result.status !== 0 || result.error)
|
|
34
|
+
throw new AppError("workspace_status_failed", "Could not determine workspace file inventory", 1, { workspace_root: root, stderr: result.stderr || null, cause: result.error?.message ?? null });
|
|
35
|
+
return [...new Set(result.stdout.split("\0").filter(Boolean))];
|
|
36
|
+
}
|
|
37
|
+
/** The exact qualified AGY file is infrastructure only while untracked.
|
|
38
|
+
* Never write a shared worktree exclude: that would hide a sibling's mixed
|
|
39
|
+
* user hooks. Snapshots still retain this qualification input. */
|
|
40
|
+
export function ownedWorkspaceHookPath(root, trackedFiles) {
|
|
41
|
+
const relative = ".agents/hooks.json", file = path.join(root, relative);
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.lstatSync(path.dirname(file)).isDirectory() || !fs.lstatSync(file).isFile() || !isDeepStrictEqual(JSON.parse(fs.readFileSync(file, "utf8")), agyWorkspaceHooks()))
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError)
|
|
48
|
+
return null;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
const tracked = trackedFiles ?? workspaceGitFiles(root, ["--cached", "--", relative]) ?? [];
|
|
52
|
+
return tracked.includes(relative) ? null : relative;
|
|
53
|
+
}
|
|
54
|
+
/** New feature worktrees do not inherit untracked qualified hooks via Git. */
|
|
55
|
+
export function propagateOwnedWorkspaceHooks(sourceRoot, targetRoot) {
|
|
56
|
+
const relative = ownedWorkspaceHookPath(sourceRoot);
|
|
57
|
+
if (!relative || path.resolve(sourceRoot) === path.resolve(targetRoot))
|
|
58
|
+
return null;
|
|
59
|
+
const target = path.join(targetRoot, relative), directory = path.dirname(target);
|
|
60
|
+
if (fs.lstatSync(target, { throwIfNoEntry: false }))
|
|
61
|
+
return null; // User/tracked target wins; daemon checks qualification.
|
|
62
|
+
if (workspaceGitFiles(targetRoot, ["--cached", "--", relative])?.includes(relative))
|
|
63
|
+
return null;
|
|
64
|
+
if (fs.lstatSync(directory, { throwIfNoEntry: false }) && !fs.lstatSync(directory).isDirectory())
|
|
65
|
+
throw new AppError("workspace_hooks_unqualified", "AGY hook directory is not an owned directory", 1, { path: directory });
|
|
66
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
67
|
+
fs.copyFileSync(path.join(sourceRoot, relative), target, fs.constants.COPYFILE_EXCL);
|
|
68
|
+
fs.chmodSync(target, 0o600);
|
|
69
|
+
return target;
|
|
70
|
+
}
|
|
71
|
+
/** Ignore only provider-owned service files. Tracked/explicit inputs win;
|
|
72
|
+
* other ignored and untracked product/task files remain snapshot payload. */
|
|
73
|
+
export function projectServicePathFilter(root, explicitInputs = [], trackedFiles) {
|
|
74
|
+
const retained = new Set([...(trackedFiles ?? workspaceGitFiles(root, ["--cached"]) ?? []), ...explicitInputs].map(file => path.resolve(root, file)));
|
|
75
|
+
const branches = new Set();
|
|
76
|
+
for (const file of retained)
|
|
77
|
+
for (let directory = path.dirname(file); directory !== path.dirname(directory); directory = path.dirname(directory))
|
|
78
|
+
branches.add(directory);
|
|
79
|
+
return file => {
|
|
80
|
+
const absolute = path.resolve(file), relative = path.relative(root, absolute);
|
|
81
|
+
return !relative.split(path.sep).includes(".zcode") || retained.has(absolute) || branches.has(absolute);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -4,11 +4,12 @@ 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";
|
|
11
11
|
import { ensureManagedWorktrunk, inspectManagedWorktrunk } from "./tooling.js";
|
|
12
|
+
import { propagateOwnedWorkspaceHooks } from "./workspace-files.js";
|
|
12
13
|
export function planWorktree(context, input) {
|
|
13
14
|
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
14
15
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
@@ -28,7 +29,7 @@ export function planWorktree(context, input) {
|
|
|
28
29
|
worktrunk: inspectManagedWorktrunk(context)
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
|
-
export function
|
|
32
|
+
export function prepareWorktreeCreate(context, input) {
|
|
32
33
|
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
33
34
|
const existing = activeWorktreeRecord(context, protocol.project_id, protocol.id);
|
|
34
35
|
if (existing) {
|
|
@@ -37,17 +38,56 @@ export function createWorktreeRecord(context, input) {
|
|
|
37
38
|
worktree_path: existing.worktree_path
|
|
38
39
|
});
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
return prepareManagedWorktree({ ...input, projectRoot: protocol.project_root });
|
|
42
|
+
}
|
|
43
|
+
export function createWorktreeRecord(context, input, prepared = prepareWorktreeCreate(context, input)) {
|
|
44
|
+
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
45
|
+
if (activeWorktreeRecord(context, protocol.project_id, protocol.id))
|
|
46
|
+
throw new AppError("worktree_already_exists", "Protocol already has an active worktree record", 1);
|
|
47
|
+
const created = createManagedWorktree(context, prepared, prepared);
|
|
48
|
+
recordManagedWorktree(context, { protocolId: protocol.id, projectRoot: protocol.project_root, branch: prepared.branch, base: prepared.base, created });
|
|
42
49
|
return { ok: true, worktree: activeWorktreeRecord(context, protocol.project_id, protocol.id) };
|
|
43
50
|
}
|
|
44
|
-
|
|
45
|
-
export function createManagedWorktree(context, input) {
|
|
51
|
+
export function prepareManagedWorktree(input) {
|
|
46
52
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
53
|
+
const invalid = (message) => { throw new AppError("validation", message, 2, { phase: "prepare", effect: "no_effect", recoverable: true }); };
|
|
54
|
+
for (const key of ["branch", "base", "path"])
|
|
55
|
+
if (typeof input[key] !== "string" || !input[key].trim() || input[key].includes("\0"))
|
|
56
|
+
invalid(`--${key} must be a non-empty value without NUL`);
|
|
57
|
+
const git = (args) => {
|
|
58
|
+
const result = spawnSync("git", ["-C", projectRoot, ...args], { encoding: "utf8", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } });
|
|
59
|
+
if (result.error)
|
|
60
|
+
throw result.error;
|
|
61
|
+
return result;
|
|
62
|
+
};
|
|
63
|
+
const branch = git(["check-ref-format", "--branch", input.branch]);
|
|
64
|
+
if (input.branch.startsWith("-") || branch.status !== 0 || branch.stdout.trim() !== input.branch)
|
|
65
|
+
invalid("--branch must be a literal valid Git branch name");
|
|
66
|
+
const base = git(["rev-parse", "--verify", "--end-of-options", `${input.base}^{commit}`]);
|
|
67
|
+
if (base.status !== 0)
|
|
68
|
+
invalid("--base must resolve to an existing Git commit");
|
|
69
|
+
if (git(["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`]).status === 0)
|
|
70
|
+
invalid("--branch already exists; choose a new feature branch");
|
|
71
|
+
const destination = canonicalPath(input.path, false);
|
|
72
|
+
if (isTrackedProjectPath(canonicalPath(projectRoot), destination))
|
|
73
|
+
invalid("--path must be outside the project checkout");
|
|
74
|
+
if (fs.existsSync(destination) && (!fs.statSync(destination).isDirectory() || fs.readdirSync(destination).length))
|
|
75
|
+
invalid("--path must be an absent or empty directory");
|
|
76
|
+
let parent = path.dirname(destination);
|
|
77
|
+
while (!fs.existsSync(parent) && path.dirname(parent) !== parent)
|
|
78
|
+
parent = path.dirname(parent);
|
|
79
|
+
if (!fs.statSync(parent).isDirectory())
|
|
80
|
+
invalid("--path has a parent that is not a directory");
|
|
81
|
+
fs.accessSync(parent, fs.constants.W_OK | fs.constants.X_OK);
|
|
82
|
+
return { projectRoot, branch: input.branch, base: base.stdout.trim(), path: destination };
|
|
83
|
+
}
|
|
84
|
+
/** Creates the checkout before a PRT exists. PROTOCOLIZE materializes the PRT only inside this checkout. */
|
|
85
|
+
export function createManagedWorktree(context, input, prepared = prepareManagedWorktree(input)) {
|
|
86
|
+
const { projectRoot } = prepared;
|
|
47
87
|
const worktrunk = ensureManagedWorktrunk(context);
|
|
48
88
|
// Worktrunk owns creation; the CLI supplies its project-scoped service path
|
|
49
89
|
// 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(
|
|
90
|
+
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
91
|
const result = runExternal(command);
|
|
52
92
|
if (result.exit_code !== 0)
|
|
53
93
|
throw new AppError("worktrunk_command_unavailable", "Worktrunk switch failed", 1, { command: sanitizeCommand(command), result });
|
|
@@ -76,14 +116,29 @@ export function recordManagedWorktree(context, input) {
|
|
|
76
116
|
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
117
|
}
|
|
78
118
|
/** 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);
|
|
119
|
+
export function prepareManagedWorktreeBootstrapSources(root) {
|
|
120
|
+
const projectRoot = resolveProjectRoot(root);
|
|
82
121
|
const includeFile = path.join(projectRoot, ".worktreeinclude");
|
|
83
122
|
const configFile = path.join(projectRoot, ".config", "wt.toml");
|
|
84
123
|
if (!fs.existsSync(includeFile) || !fs.existsSync(configFile)) {
|
|
85
124
|
throw new AppError("bootstrap_unavailable", "Project-owned Worktrunk bootstrap files are required", 1, { include_file: includeFile, config_file: configFile });
|
|
86
125
|
}
|
|
126
|
+
for (const file of [includeFile, configFile]) {
|
|
127
|
+
if (!fs.statSync(file).isFile())
|
|
128
|
+
throw new AppError("bootstrap_unavailable", "Project-owned bootstrap inputs must be regular files", 1, { file });
|
|
129
|
+
fs.accessSync(file, fs.constants.R_OK);
|
|
130
|
+
}
|
|
131
|
+
return projectRoot;
|
|
132
|
+
}
|
|
133
|
+
export function prepareManagedWorktreeBootstrap(input) {
|
|
134
|
+
const projectRoot = prepareManagedWorktreeBootstrapSources(input.projectRoot);
|
|
135
|
+
const worktreePath = path.resolve(input.worktreePath);
|
|
136
|
+
if (!fs.statSync(worktreePath).isDirectory())
|
|
137
|
+
throw new AppError("bootstrap_unavailable", "Bootstrap worktree must be a directory", 1, { worktree_path: worktreePath });
|
|
138
|
+
return { projectRoot, worktreePath };
|
|
139
|
+
}
|
|
140
|
+
export function bootstrapManagedWorktree(context, input, prepared = prepareManagedWorktreeBootstrap(input)) {
|
|
141
|
+
const { worktreePath } = prepared;
|
|
87
142
|
const worktrunk = ensureManagedWorktrunk(context);
|
|
88
143
|
const commands = [
|
|
89
144
|
[worktrunk.bin, "-C", worktreePath, "step", "copy-ignored", "--require-include"],
|
|
@@ -97,44 +152,37 @@ export function bootstrapManagedWorktree(context, input) {
|
|
|
97
152
|
throw new AppError("bootstrap_failed", "Project-owned Worktrunk bootstrap failed", 1, { command: sanitizeCommand(command), result, results });
|
|
98
153
|
}
|
|
99
154
|
}
|
|
100
|
-
|
|
155
|
+
const qualifiedHook = propagateOwnedWorkspaceHooks(prepared.projectRoot, worktreePath);
|
|
156
|
+
return { status: "succeeded", commands: results, ...(qualifiedHook ? { qualified_hook: qualifiedHook } : {}) };
|
|
101
157
|
}
|
|
102
158
|
export function getWorktreeStatus(context, input) {
|
|
103
159
|
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
104
160
|
return { ok: true, protocol_id: protocol.id, worktree: worktreeRecord(context, protocol.project_id, protocol.id), worktrunk: inspectManagedWorktrunk(context) };
|
|
105
161
|
}
|
|
106
|
-
export function
|
|
162
|
+
export function prepareWorktreeBootstrap(context, input) {
|
|
107
163
|
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
108
164
|
const record = activeWorktreeRecord(context, protocol.project_id, protocol.id);
|
|
109
165
|
if (!record) {
|
|
110
166
|
throw new AppError("not_found", `Worktree record is not active for protocol: ${protocol.id}`, 1);
|
|
111
167
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
168
|
+
return { protocol, record, managed: prepareManagedWorktreeBootstrap({ projectRoot: protocol.project_root, worktreePath: record.worktree_path }) };
|
|
169
|
+
}
|
|
170
|
+
export function bootstrapWorktree(context, input, prepared = prepareWorktreeBootstrap(context, input)) {
|
|
171
|
+
const { protocol, record } = prepared;
|
|
172
|
+
const active = activeWorktreeRecord(context, protocol.project_id, protocol.id);
|
|
173
|
+
if (!active || active.worktree_path !== record.worktree_path)
|
|
174
|
+
throw new AppError("worktree_active", "Worktree changed after bootstrap preparation", 1);
|
|
175
|
+
let result;
|
|
176
|
+
try {
|
|
177
|
+
result = bootstrapManagedWorktree(context, prepared.managed, prepared.managed);
|
|
120
178
|
}
|
|
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
|
-
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error instanceof AppError && error.code === "bootstrap_failed")
|
|
181
|
+
markBootstrapFailed(context, protocol, record, error.details);
|
|
182
|
+
throw error;
|
|
135
183
|
}
|
|
136
184
|
const now = context.now();
|
|
137
|
-
const
|
|
185
|
+
const results = result.commands;
|
|
138
186
|
context.db.run(`UPDATE worktree_records
|
|
139
187
|
SET bootstrap_status = 'succeeded', last_command_result_json = ?, updated_at = ?
|
|
140
188
|
WHERE project_id = ? AND protocol_id = ?`, [JSON.stringify(result), now, protocol.project_id, protocol.id]);
|
|
@@ -155,7 +203,7 @@ export function bootstrapWorktree(context, input) {
|
|
|
155
203
|
});
|
|
156
204
|
return { ok: true, worktree: worktreeRecord(context, protocol.project_id, protocol.id) };
|
|
157
205
|
}
|
|
158
|
-
export function
|
|
206
|
+
export function prepareWorktreeClose(context, input) {
|
|
159
207
|
if (!["keep", "remove"].includes(input.mode)) {
|
|
160
208
|
throw new AppError("validation", "--mode must be keep or remove", 2);
|
|
161
209
|
}
|
|
@@ -171,6 +219,10 @@ export function closeWorktree(context, input) {
|
|
|
171
219
|
stage: state.stage
|
|
172
220
|
});
|
|
173
221
|
}
|
|
222
|
+
return { protocol, record };
|
|
223
|
+
}
|
|
224
|
+
export function closeWorktree(context, input) {
|
|
225
|
+
const { protocol, record } = prepareWorktreeClose(context, input);
|
|
174
226
|
const now = context.now();
|
|
175
227
|
const nextStatus = input.mode === "keep" ? "kept" : fs.existsSync(record.worktree_path) ? "kept" : "removed";
|
|
176
228
|
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]);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Stable model-facing reference for persisted project, RUN and Work IDs. */
|
|
2
|
+
export function shortEntityReference(id) {
|
|
3
|
+
return /^(?:PRJ|RUN|WRK)-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id;
|
|
4
|
+
}
|
|
5
|
+
/** Accepted spellings of one already-resolved entity. Wrong full slugs stay distinct. */
|
|
6
|
+
export function entityReferenceVariants(id) {
|
|
7
|
+
return [...new Set([id, shortEntityReference(id)])];
|
|
8
|
+
}
|
package/dist/shared/errors.js
CHANGED
|
@@ -9,6 +9,18 @@ export class AppError extends Error {
|
|
|
9
9
|
this.details = details;
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
/** Error fields must cross JSON boundaries as data: Error.message is not enumerable. */
|
|
13
|
+
export function errorRecord(error, depth = 0) {
|
|
14
|
+
const value = error;
|
|
15
|
+
const details = value?.details && typeof value.details === "object" && !Array.isArray(value.details) ? value.details : {};
|
|
16
|
+
return {
|
|
17
|
+
code: typeof value?.code === "string" ? value.code : "unexpected",
|
|
18
|
+
message: typeof value?.message === "string" ? value.message : String(error),
|
|
19
|
+
exitCode: typeof value?.exitCode === "number" ? value.exitCode : 1,
|
|
20
|
+
details,
|
|
21
|
+
...(value?.cause && depth < 3 ? { cause: errorRecord(value.cause, depth + 1) } : {})
|
|
22
|
+
};
|
|
23
|
+
}
|
|
12
24
|
export function isAppError(error) {
|
|
13
25
|
return error instanceof AppError;
|
|
14
26
|
}
|