@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100
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 +561 -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 +91 -15
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/run-cli.js +1506 -334
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -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/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -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 +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -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 +267 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -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 +70 -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/runtime/context.js +3 -1
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-decision.schema.json +1 -1
- 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 +75 -21
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +676 -79
- 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 +134 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +125 -80
- package/dist/services/eval-snapshots.js +838 -74
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +609 -310
- package/dist/services/lanes.js +62 -53
- package/dist/services/lifecycle-command.js +103 -17
- package/dist/services/lifecycle-contract.js +30 -0
- package/dist/services/lifecycle-invocations.js +1128 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +425 -0
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +78 -27
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +27 -18
- package/dist/services/protocols.js +64 -20
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +222 -0
- package/dist/services/run-controller-capture.js +136 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +803 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-projection.js +10 -8
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +343 -0
- package/dist/services/runs.js +437 -81
- package/dist/services/runtime-budget.js +439 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +21 -19
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +61 -80
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +112 -108
- package/dist/services/stage-pause.js +117 -73
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +108 -132
- package/dist/services/vnext-code-review.js +245 -98
- package/dist/services/vnext-code.js +355 -257
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +166 -26
- package/dist/services/vnext-merge.js +413 -147
- package/dist/services/vnext-plan-review.js +182 -109
- package/dist/services/vnext-plan.js +199 -82
- package/dist/services/vnext-protocolize.js +133 -60
- package/dist/services/vnext-specify.js +94 -71
- package/dist/services/work-registry.js +813 -160
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +569 -16
- package/dist/storage/paths.js +17 -4
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +24 -13
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
- package/tools/repair-paused-run-status.mjs +59 -0
package/dist/services/lanes.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
2
|
import { spawnSync } from "node:child_process";
|
|
5
3
|
import { AppError } from "../shared/errors.js";
|
|
6
4
|
import { appendAudit } from "./audit.js";
|
|
@@ -8,6 +6,32 @@ import { requireProjectByRoot } from "./projects.js";
|
|
|
8
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
9
7
|
const defaultTtlSeconds = 300;
|
|
10
8
|
const defaultPollIntervalSeconds = 10;
|
|
9
|
+
export function prepareLaneWaitTiming(input) {
|
|
10
|
+
return {
|
|
11
|
+
timeoutSeconds: normalizeNonNegativeNumber(input.timeoutSeconds, 0, "timeout"),
|
|
12
|
+
pollIntervalSeconds: normalizePositiveNumber(input.pollIntervalSeconds, defaultPollIntervalSeconds, "poll-interval")
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function prepareLaneLock(context, input, action) {
|
|
16
|
+
if (typeof input.workerId !== "string" || !input.workerId.trim())
|
|
17
|
+
throw new AppError("validation", "Lane lock requires a nonempty worker ID", 2);
|
|
18
|
+
if (["acquire", "release", "wait-acquire"].includes(action) && (typeof input.reason !== "string" || !input.reason.trim()))
|
|
19
|
+
throw new AppError("validation", "Lane lock requires a nonempty reason", 2);
|
|
20
|
+
if (input.leaseToken !== undefined && (typeof input.leaseToken !== "string" || !input.leaseToken.trim()))
|
|
21
|
+
throw new AppError("validation", "Lease token must be nonempty text", 2);
|
|
22
|
+
const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
|
|
23
|
+
const { timeoutSeconds, pollIntervalSeconds } = prepareLaneWaitTiming(input);
|
|
24
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
25
|
+
const lane = normalizeLane(input.lane);
|
|
26
|
+
if (input.workspacePath !== undefined)
|
|
27
|
+
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
28
|
+
else if (["acquire", "wait", "wait-acquire"].includes(action))
|
|
29
|
+
throw new AppError("validation", "Lane lock requires a workspace path", 2);
|
|
30
|
+
if (action === "acquire" || action === "wait-acquire")
|
|
31
|
+
assertWorkerMayAcquireLaneLock(context, project.id, lane, input.workerId);
|
|
32
|
+
const lock = action === "heartbeat" || action === "release" ? requireOwnedActiveLock(context, project.id, lane, input.workerId, input.leaseToken) : undefined;
|
|
33
|
+
return { project, lane, ttlSeconds, timeoutSeconds, pollIntervalSeconds, lock };
|
|
34
|
+
}
|
|
11
35
|
export function getLaneStatus(context, input) {
|
|
12
36
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
13
37
|
return {
|
|
@@ -23,10 +47,16 @@ export function expireProjectLaneLocks(context, projectId) {
|
|
|
23
47
|
export function expireProjectLaneWaiters(context, projectId) {
|
|
24
48
|
expireStaleWaiters(context, projectId);
|
|
25
49
|
}
|
|
26
|
-
export function
|
|
50
|
+
export function prepareLaneWorkspace(context, input) {
|
|
51
|
+
if (input.branch !== undefined && (typeof input.branch !== "string" || !input.branch.trim()))
|
|
52
|
+
throw new AppError("validation", "Expected branch must be nonempty text", 2);
|
|
27
53
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
28
54
|
const lane = normalizeLane(input.lane);
|
|
29
55
|
const workspacePath = resolveExistingPath(input.workspacePath);
|
|
56
|
+
return { project, lane, workspacePath };
|
|
57
|
+
}
|
|
58
|
+
export function setLaneWorkspace(context, input) {
|
|
59
|
+
const { project, lane, workspacePath } = prepareLaneWorkspace(context, input);
|
|
30
60
|
const now = context.now();
|
|
31
61
|
const existing = laneRecord(context, project.id, lane);
|
|
32
62
|
context.db.run(`INSERT INTO lanes
|
|
@@ -69,11 +99,7 @@ export function checkLaneWorkspace(context, input) {
|
|
|
69
99
|
};
|
|
70
100
|
}
|
|
71
101
|
export function acquireLaneLock(context, input) {
|
|
72
|
-
const project =
|
|
73
|
-
const lane = normalizeLane(input.lane);
|
|
74
|
-
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
75
|
-
assertWorkerMayAcquireLaneLock(context, project.id, lane, input.workerId);
|
|
76
|
-
const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
|
|
102
|
+
const { project, lane, ttlSeconds } = prepareLaneLock(context, input, "acquire");
|
|
77
103
|
expireStaleLocks(context, project.id);
|
|
78
104
|
const active = activeLaneLock(context, project.id, lane);
|
|
79
105
|
if (active) {
|
|
@@ -116,13 +142,8 @@ export function acquireLaneLock(context, input) {
|
|
|
116
142
|
};
|
|
117
143
|
}
|
|
118
144
|
export function heartbeatLaneLock(context, input) {
|
|
119
|
-
const project =
|
|
120
|
-
const
|
|
121
|
-
if (input.workspacePath) {
|
|
122
|
-
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
123
|
-
}
|
|
124
|
-
const lock = requireOwnedActiveLock(context, project.id, lane, input.workerId, input.leaseToken);
|
|
125
|
-
const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
|
|
145
|
+
const { project, lock: preparedLock, ttlSeconds } = prepareLaneLock(context, input, "heartbeat");
|
|
146
|
+
const lock = preparedLock;
|
|
126
147
|
const now = context.now();
|
|
127
148
|
context.db.run(`UPDATE lane_locks SET heartbeat_at = ?, expires_at = ?, updated_at = ? WHERE id = ?`, [now, expiresAt(now, ttlSeconds), now, lock.id]);
|
|
128
149
|
appendAudit(context, {
|
|
@@ -133,12 +154,8 @@ export function heartbeatLaneLock(context, input) {
|
|
|
133
154
|
return { ok: true, lock: activeLaneLock(context, project.id, lock.lane) };
|
|
134
155
|
}
|
|
135
156
|
export function releaseLaneLock(context, input) {
|
|
136
|
-
const project =
|
|
137
|
-
const
|
|
138
|
-
if (input.workspacePath) {
|
|
139
|
-
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
140
|
-
}
|
|
141
|
-
const lock = requireOwnedActiveLock(context, project.id, lane, input.workerId, input.leaseToken);
|
|
157
|
+
const { project, lock: preparedLock } = prepareLaneLock(context, input, "release");
|
|
158
|
+
const lock = preparedLock;
|
|
142
159
|
const now = context.now();
|
|
143
160
|
context.db.run(`UPDATE lane_locks
|
|
144
161
|
SET status = 'released', released_at = ?, reason = ?, updated_at = ?
|
|
@@ -152,11 +169,7 @@ export function releaseLaneLock(context, input) {
|
|
|
152
169
|
return { ok: true, lock: laneLockById(context, lock.id) };
|
|
153
170
|
}
|
|
154
171
|
export async function waitForLaneLock(context, input) {
|
|
155
|
-
const project =
|
|
156
|
-
const lane = normalizeLane(input.lane);
|
|
157
|
-
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
158
|
-
const timeoutSeconds = normalizeNonNegativeNumber(input.timeoutSeconds, 0, "timeout");
|
|
159
|
-
const pollIntervalSeconds = normalizePositiveNumber(input.pollIntervalSeconds, defaultPollIntervalSeconds, "poll-interval");
|
|
172
|
+
const { project, lane, timeoutSeconds, pollIntervalSeconds } = prepareLaneLock(context, input, "wait");
|
|
160
173
|
const start = Date.now();
|
|
161
174
|
while (true) {
|
|
162
175
|
expireStaleLocks(context, project.id);
|
|
@@ -186,13 +199,18 @@ export function getLaneWaiters(context, input) {
|
|
|
186
199
|
}))
|
|
187
200
|
};
|
|
188
201
|
}
|
|
189
|
-
export function
|
|
202
|
+
export function prepareLaneWaiterCancellation(context, input) {
|
|
203
|
+
if (typeof input.workerId !== "string" || !input.workerId.trim())
|
|
204
|
+
throw new AppError("validation", "Lane waiter requires a nonempty worker ID", 2);
|
|
205
|
+
if (typeof input.reason !== "string" || !input.reason.trim())
|
|
206
|
+
throw new AppError("validation", "lane waiter cancel requires --reason", 2);
|
|
190
207
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
191
208
|
const lane = normalizeLane(input.lane);
|
|
192
209
|
const reason = input.reason.trim();
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
210
|
+
return { project, lane, reason };
|
|
211
|
+
}
|
|
212
|
+
export function cancelLaneWaiter(context, input) {
|
|
213
|
+
const { project, lane, reason } = prepareLaneWaiterCancellation(context, input);
|
|
196
214
|
expireStaleWaiters(context, project.id);
|
|
197
215
|
const now = context.now();
|
|
198
216
|
const waiter = activeLaneWaiterForWorker(context, project.id, lane, input.workerId);
|
|
@@ -245,13 +263,7 @@ export function expireLaneWaiterById(context, input) {
|
|
|
245
263
|
return { ok: true, expired: result.changes === 1, waiter_id: input.waiterId };
|
|
246
264
|
}
|
|
247
265
|
export async function waitAcquireLaneLock(context, input) {
|
|
248
|
-
const project =
|
|
249
|
-
const lane = normalizeLane(input.lane);
|
|
250
|
-
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
251
|
-
assertWorkerMayAcquireLaneLock(context, project.id, lane, input.workerId);
|
|
252
|
-
const timeoutSeconds = normalizeNonNegativeNumber(input.timeoutSeconds, 0, "timeout");
|
|
253
|
-
const pollIntervalSeconds = normalizePositiveNumber(input.pollIntervalSeconds, defaultPollIntervalSeconds, "poll-interval");
|
|
254
|
-
const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
|
|
266
|
+
const { project, lane, timeoutSeconds, pollIntervalSeconds, ttlSeconds } = prepareLaneLock(context, input, "wait-acquire");
|
|
255
267
|
const startedAt = Date.now();
|
|
256
268
|
const waiterDeadline = timeoutSeconds > 0 ? new Date(startedAt + timeoutSeconds * 1000).toISOString() : null;
|
|
257
269
|
while (true) {
|
|
@@ -288,6 +300,7 @@ export async function waitAcquireLaneLock(context, input) {
|
|
|
288
300
|
timeoutSeconds
|
|
289
301
|
});
|
|
290
302
|
}
|
|
303
|
+
input.progress?.(`lane ${lane} is waiting at queue position ${String(result.payload.position ?? "unknown")}; next update in ${pollIntervalSeconds} seconds`);
|
|
291
304
|
await delay(pollIntervalSeconds * 1000);
|
|
292
305
|
}
|
|
293
306
|
}
|
|
@@ -374,10 +387,9 @@ function laneWaitersForProject(context, projectId, lane) {
|
|
|
374
387
|
LIMIT 100`, [projectId]);
|
|
375
388
|
}
|
|
376
389
|
function activeLaneLock(context, projectId, lane) {
|
|
377
|
-
expireStaleLocks(context, projectId);
|
|
378
390
|
return context.db.get(`SELECT * FROM lane_locks
|
|
379
|
-
WHERE project_id = ? AND lane = ? AND status = 'active'
|
|
380
|
-
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, lane]);
|
|
391
|
+
WHERE project_id = ? AND lane = ? AND status = 'active' AND expires_at > ?
|
|
392
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, lane, context.now()]);
|
|
381
393
|
}
|
|
382
394
|
function activeLaneWaiterForWorker(context, projectId, lane, workerId) {
|
|
383
395
|
return context.db.get(`SELECT * FROM lane_waiters
|
|
@@ -512,10 +524,10 @@ function ensureQueuedLaneWaiter(context, input) {
|
|
|
512
524
|
return waiter;
|
|
513
525
|
}
|
|
514
526
|
function tryAcquireQueuedLaneLock(context, input) {
|
|
515
|
-
|
|
516
|
-
expireStaleWaiters(context, input.projectId);
|
|
517
|
-
context.db.exec("BEGIN IMMEDIATE");
|
|
527
|
+
context.db.beginWriteTransaction();
|
|
518
528
|
try {
|
|
529
|
+
expireStaleLocks(context, input.projectId);
|
|
530
|
+
expireStaleWaiters(context, input.projectId);
|
|
519
531
|
const existingLock = activeLaneLock(context, input.projectId, input.lane);
|
|
520
532
|
if (existingLock?.worker_id === input.workerId) {
|
|
521
533
|
context.db.exec("COMMIT");
|
|
@@ -617,11 +629,7 @@ function tryAcquireQueuedLaneLock(context, input) {
|
|
|
617
629
|
}
|
|
618
630
|
}
|
|
619
631
|
function resolveExistingPath(value) {
|
|
620
|
-
|
|
621
|
-
if (!fs.existsSync(absolute)) {
|
|
622
|
-
throw new AppError("not_found", `Workspace path does not exist: ${absolute}`, 1);
|
|
623
|
-
}
|
|
624
|
-
return fs.realpathSync(absolute);
|
|
632
|
+
return resolveProjectRoot(value);
|
|
625
633
|
}
|
|
626
634
|
function detectBranch(workspacePath) {
|
|
627
635
|
const result = spawnSync("git", ["-C", workspacePath, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
@@ -634,7 +642,7 @@ function expiresAt(now, ttlSeconds) {
|
|
|
634
642
|
return new Date(new Date(now).getTime() + ttlSeconds * 1000).toISOString();
|
|
635
643
|
}
|
|
636
644
|
function normalizeLane(value) {
|
|
637
|
-
const normalized = value.trim().toLowerCase();
|
|
645
|
+
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
638
646
|
if (!normalized || !/^[a-z0-9._-]+$/.test(normalized)) {
|
|
639
647
|
throw new AppError("validation", "Lane name must contain only lowercase letters, numbers, dot, underscore, or hyphen", 2);
|
|
640
648
|
}
|
|
@@ -642,15 +650,16 @@ function normalizeLane(value) {
|
|
|
642
650
|
}
|
|
643
651
|
function normalizePositiveNumber(value, fallback, label) {
|
|
644
652
|
const normalized = value ?? fallback;
|
|
645
|
-
if (!Number.isFinite(normalized) || normalized <= 0
|
|
646
|
-
|
|
653
|
+
if (!Number.isFinite(normalized) || normalized <= 0 ||
|
|
654
|
+
(label === "poll-interval" ? normalized * 1000 > 2 ** 31 - 1 : !Number.isFinite(new Date(Date.now() + normalized * 1000).getTime()))) {
|
|
655
|
+
throw new AppError("validation", `--${label} must be a positive number within the supported timer/date range`, 2);
|
|
647
656
|
}
|
|
648
657
|
return normalized;
|
|
649
658
|
}
|
|
650
659
|
function normalizeNonNegativeNumber(value, fallback, label) {
|
|
651
660
|
const normalized = value ?? fallback;
|
|
652
|
-
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
653
|
-
throw new AppError("validation", `--${label} must be zero or a positive number`, 2);
|
|
661
|
+
if (!Number.isFinite(normalized) || normalized < 0 || !Number.isFinite(new Date(Date.now() + normalized * 1000).getTime())) {
|
|
662
|
+
throw new AppError("validation", `--${label} must be zero or a positive number within the supported date range`, 2);
|
|
654
663
|
}
|
|
655
664
|
return normalized;
|
|
656
665
|
}
|
|
@@ -1,11 +1,45 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { parse, quote } from "shell-quote";
|
|
3
|
+
import { lifecycleOperationForArgv } from "./lifecycle-contract.js";
|
|
4
|
+
/** Native hooks and the lightweight ingress must recognize the same shell. */
|
|
5
|
+
export function lifecycleCommandFromPayload(payload) {
|
|
6
|
+
const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
|
|
7
|
+
const command = [payload.command, input.command, input.cmd].find(value => typeof value === "string" && value.length > 0);
|
|
8
|
+
return typeof command === "string" ? unwrapShellCommand(command) : undefined;
|
|
9
|
+
}
|
|
3
10
|
const controlOperators = new Set(["&&", "||", ";", "|", "|&", "&", "(", ")", "<(", ">("]);
|
|
4
11
|
const redirectionOperators = new Set(["<", ">", ">>", ">|", "<&", ">&", "<<<"]);
|
|
5
12
|
/** Parse the deliberately small direct-command surface accepted by lifecycle hooks. */
|
|
6
13
|
export function parseLifecycleCommand(command) {
|
|
7
14
|
return parseLifecycleCommandInner(command, false);
|
|
8
15
|
}
|
|
16
|
+
/** Exact diagnostic routes; these deliberately confer no lifecycle authority. */
|
|
17
|
+
export function continuationCommand(command) {
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = parse(command, name => `$${name}`);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (!entries.every(entry => typeof entry === "string"))
|
|
26
|
+
return null;
|
|
27
|
+
const words = entries;
|
|
28
|
+
const start = words.findIndex(word => ["stage", "work"].includes(word));
|
|
29
|
+
if (start < 1)
|
|
30
|
+
return null;
|
|
31
|
+
const launcher = words.slice(0, start);
|
|
32
|
+
while (launcher[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(launcher[0]))
|
|
33
|
+
launcher.shift();
|
|
34
|
+
const direct = launcher.length === 1 && (path.basename(launcher[0]) === "dd-flow" || launcher[0] === "$DD_FLOW_BIN");
|
|
35
|
+
const node = launcher.length === 2 && path.basename(launcher[0]) === "node" && (path.basename(launcher[1]) === "cli.js" || launcher[1] === "$DD_FLOW_BIN");
|
|
36
|
+
if (!direct && !node)
|
|
37
|
+
return null;
|
|
38
|
+
const args = parseCommandArgs(words.slice(start));
|
|
39
|
+
const route = args.positional.slice(0, 3).join(" ");
|
|
40
|
+
const operation = route === "work repair add" ? "work_repair_add" : args.positional[0] === "stage" && args.positional[1] === "block" ? "stage_block" : null;
|
|
41
|
+
return operation ? { operation, args } : null;
|
|
42
|
+
}
|
|
9
43
|
/** Preserve the historical Desktop contract: execute the payload, not its one-command shell wrapper. */
|
|
10
44
|
export function unwrapShellCommand(command) {
|
|
11
45
|
let entries;
|
|
@@ -32,7 +66,7 @@ function parseLifecycleCommandInner(command, wrapped) {
|
|
|
32
66
|
const heredoc = stripHeredocBodies(command);
|
|
33
67
|
let entries;
|
|
34
68
|
try {
|
|
35
|
-
entries = parse(heredoc.command, (name) => `$${name}`);
|
|
69
|
+
entries = parse(withoutFileDescriptors(heredoc.command), (name) => `$${name}`);
|
|
36
70
|
}
|
|
37
71
|
catch {
|
|
38
72
|
// A command that cannot be tokenized cannot create a trusted claim. The
|
|
@@ -56,11 +90,11 @@ function parseLifecycleCommandInner(command, wrapped) {
|
|
|
56
90
|
if (!found.length)
|
|
57
91
|
return { kind: "none" };
|
|
58
92
|
const first = found[0];
|
|
59
|
-
if (found.length !== 1 || composed || first.nestedCompound) {
|
|
93
|
+
if (found.length !== 1 || composed || first.nestedCompound || (heredoc.present && !heredoc.safe)) {
|
|
60
94
|
return {
|
|
61
95
|
kind: "compound",
|
|
62
96
|
invocation: { ...first.invocation, rewriteSafe: false },
|
|
63
|
-
reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : "shell_composition"
|
|
97
|
+
reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : heredoc.present && !heredoc.safe ? "unsafe_heredoc" : "shell_composition"
|
|
64
98
|
};
|
|
65
99
|
}
|
|
66
100
|
return { kind: "standalone", invocation: first.invocation };
|
|
@@ -127,7 +161,7 @@ function invocationFromSegment(segment, heredoc, wrapped, inheritedEnv) {
|
|
|
127
161
|
}
|
|
128
162
|
}
|
|
129
163
|
const executable = segment.words[index];
|
|
130
|
-
if (!executable || path.basename(executable) !== "dd-flow")
|
|
164
|
+
if (!executable || path.basename(executable) !== "dd-flow" && executable !== "$DD_FLOW_BIN")
|
|
131
165
|
return null;
|
|
132
166
|
const argv = segment.words.slice(index + 1);
|
|
133
167
|
const operation = lifecycleOperation(argv);
|
|
@@ -138,7 +172,7 @@ function invocationFromSegment(segment, heredoc, wrapped, inheritedEnv) {
|
|
|
138
172
|
executable,
|
|
139
173
|
argv,
|
|
140
174
|
env,
|
|
141
|
-
args: parseCommandArgs(argv.slice(2)),
|
|
175
|
+
args: parseCommandArgs(argv.slice(operation === "recovery_accept" ? 3 : 2)),
|
|
142
176
|
command: quote(segment.words),
|
|
143
177
|
rewriteSafe: !heredoc && !wrapped && !segment.dynamic,
|
|
144
178
|
wrapped,
|
|
@@ -170,16 +204,7 @@ function wrappedInvocationFromSegment(segment) {
|
|
|
170
204
|
return parseLifecycleCommandInner(payload, true);
|
|
171
205
|
}
|
|
172
206
|
function lifecycleOperation(argv) {
|
|
173
|
-
|
|
174
|
-
if (key === "session register")
|
|
175
|
-
return "session_register";
|
|
176
|
-
if (key === "stage start")
|
|
177
|
-
return "stage_start";
|
|
178
|
-
if (key === "stage resume")
|
|
179
|
-
return "stage_resume";
|
|
180
|
-
if (key === "work start")
|
|
181
|
-
return "work_start";
|
|
182
|
-
return null;
|
|
207
|
+
return lifecycleOperationForArgv(argv);
|
|
183
208
|
}
|
|
184
209
|
export function parseCommandArgs(argv) {
|
|
185
210
|
const positional = [];
|
|
@@ -187,6 +212,12 @@ export function parseCommandArgs(argv) {
|
|
|
187
212
|
for (let index = 0; index < argv.length; index += 1) {
|
|
188
213
|
const value = argv[index];
|
|
189
214
|
if (value?.startsWith("--")) {
|
|
215
|
+
const equals = value.indexOf("=");
|
|
216
|
+
if (equals > 2) {
|
|
217
|
+
const key = value.slice(2, equals);
|
|
218
|
+
options.set(key, [...(options.get(key) ?? []), value.slice(equals + 1)]);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
190
221
|
const key = value.slice(2);
|
|
191
222
|
const next = argv[index + 1];
|
|
192
223
|
const optionValue = !next || next.startsWith("--") ? "__dd_flow_flag__" : next;
|
|
@@ -213,6 +244,58 @@ export function commandPosition(invocation, index) {
|
|
|
213
244
|
function assignment(word) {
|
|
214
245
|
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
|
|
215
246
|
}
|
|
247
|
+
/** Source offsets matter: shell-quote loses the difference between `2>&1`
|
|
248
|
+
* and the literal argument `2 >&1`. Only inspect unquoted shell syntax. */
|
|
249
|
+
function shellSyntaxOffsets(command) {
|
|
250
|
+
const offsets = [];
|
|
251
|
+
let quoted = null, escaped = false;
|
|
252
|
+
for (let i = 0; i < command.length; i++) {
|
|
253
|
+
const char = command[i];
|
|
254
|
+
if (escaped) {
|
|
255
|
+
escaped = false;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (char === "\\" && quoted !== "'") {
|
|
259
|
+
escaped = true;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (quoted) {
|
|
263
|
+
if (char === quoted)
|
|
264
|
+
quoted = null;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (char === "'" || char === '"') {
|
|
268
|
+
quoted = char;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
|
|
272
|
+
offsets.push(i);
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
if (char === "<" || char === ">")
|
|
276
|
+
offsets.push(i);
|
|
277
|
+
}
|
|
278
|
+
return offsets;
|
|
279
|
+
}
|
|
280
|
+
function descriptorStart(command, index) {
|
|
281
|
+
let start = index;
|
|
282
|
+
while (start > 0 && /[0-9]/.test(command[start - 1]))
|
|
283
|
+
start--;
|
|
284
|
+
return start === 0 || /[\s;&|]/.test(command[start - 1]) ? start : index;
|
|
285
|
+
}
|
|
286
|
+
export function shellSuffixOffset(command) {
|
|
287
|
+
const offset = shellSyntaxOffsets(command)[0];
|
|
288
|
+
return offset === undefined ? command.length : descriptorStart(command, offset);
|
|
289
|
+
}
|
|
290
|
+
function withoutFileDescriptors(command) {
|
|
291
|
+
for (const index of shellSyntaxOffsets(command).reverse()) {
|
|
292
|
+
if (command[index] === "#")
|
|
293
|
+
continue;
|
|
294
|
+
const start = descriptorStart(command, index);
|
|
295
|
+
command = command.slice(0, start) + command.slice(index);
|
|
296
|
+
}
|
|
297
|
+
return command;
|
|
298
|
+
}
|
|
216
299
|
function addAssignment(env, word) {
|
|
217
300
|
const separator = word.indexOf("=");
|
|
218
301
|
env[word.slice(0, separator)] = word.slice(separator + 1);
|
|
@@ -223,6 +306,7 @@ function stripHeredocBodies(command) {
|
|
|
223
306
|
const pending = [];
|
|
224
307
|
let active;
|
|
225
308
|
let present = false;
|
|
309
|
+
let count = 0, quoted = true;
|
|
226
310
|
for (const line of lines) {
|
|
227
311
|
if (active) {
|
|
228
312
|
const candidate = active.stripTabs ? line.replace(/^\t+/, "") : line;
|
|
@@ -233,13 +317,15 @@ function stripHeredocBodies(command) {
|
|
|
233
317
|
if (line.trim())
|
|
234
318
|
output.push(line);
|
|
235
319
|
const found = heredocDelimiters(line);
|
|
320
|
+
count += found.length;
|
|
321
|
+
quoted &&= found.every(item => item.quoted);
|
|
236
322
|
if (found.length) {
|
|
237
323
|
present = true;
|
|
238
324
|
active = found[0];
|
|
239
325
|
pending.push(...found.slice(1));
|
|
240
326
|
}
|
|
241
327
|
}
|
|
242
|
-
return { command: output.join(" ; "), present };
|
|
328
|
+
return { command: output.join(" ; "), present, safe: count === 1 && quoted && !active && pending.length === 0 };
|
|
243
329
|
}
|
|
244
330
|
function heredocDelimiters(line) {
|
|
245
331
|
const result = [];
|
|
@@ -282,7 +368,7 @@ function heredocDelimiters(line) {
|
|
|
282
368
|
index += 1;
|
|
283
369
|
const delimiter = line.slice(start, index);
|
|
284
370
|
if (delimiter)
|
|
285
|
-
result.push({ delimiter, stripTabs });
|
|
371
|
+
result.push({ delimiter, stripTabs, quoted: delimiterQuote !== null });
|
|
286
372
|
}
|
|
287
373
|
return result;
|
|
288
374
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const lifecycleRoutes = {
|
|
2
|
+
"session register": "session_register",
|
|
3
|
+
"stage start": "stage_start",
|
|
4
|
+
"stage finish": "stage_finish",
|
|
5
|
+
"stage pause": "stage_pause",
|
|
6
|
+
"stage resume": "stage_resume",
|
|
7
|
+
"work start": "work_start",
|
|
8
|
+
"work finish": "work_finish",
|
|
9
|
+
"work fail": "work_fail",
|
|
10
|
+
"merge apply": "merge_apply",
|
|
11
|
+
"merge repair": "merge_repair",
|
|
12
|
+
"run recovery accept": "recovery_accept"
|
|
13
|
+
};
|
|
14
|
+
export const lifecycleInternalOptions = ["invocation-id", "hook-event-id"];
|
|
15
|
+
export function lifecycleOperationForArgv(argv) {
|
|
16
|
+
if (argv.some(arg => arg === "--help" || arg === "-h"))
|
|
17
|
+
return null;
|
|
18
|
+
for (const [route, operation] of Object.entries(lifecycleRoutes)) {
|
|
19
|
+
const parts = route.split(" ");
|
|
20
|
+
if (parts.every((part, index) => argv[index] === part))
|
|
21
|
+
return operation;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export function lifecycleRouteForOperation(operation) {
|
|
26
|
+
const entry = Object.entries(lifecycleRoutes).find(([, value]) => value === operation);
|
|
27
|
+
if (!entry)
|
|
28
|
+
throw new Error(`Unknown lifecycle operation: ${operation}`);
|
|
29
|
+
return entry[0];
|
|
30
|
+
}
|