@deksden-com/dd-flow-cli 0.9.0-beta.80 → 0.9.0-beta.82
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 +12 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/run-cli.js +2 -1
- package/dist/services/hooks.js +35 -21
- package/dist/services/lifecycle-invocations.js +15 -8
- package/dist/services/run-recovery.js +3 -2
- package/dist/services/stage-pause.js +6 -7
- package/dist/services/work-registry.js +13 -27
- package/dist/shared/entity-references.js +8 -0
- package/dist/storage/work-references.js +23 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.82
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Keep release registry readback running through delayed npm propagation while still failing authorization errors immediately.
|
|
8
|
+
|
|
9
|
+
## 0.9.0-beta.81
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- a48e3de: Canonicalize short and full RUN/Work references across native hook receipts, lifecycle admission, Work settlement, and HITL pause/resume. Keep model-facing commands short while preserving scoped ownership checks and report precise receipt mismatch reasons.
|
|
14
|
+
|
|
3
15
|
## 0.9.0-beta.75
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.82",
|
|
4
|
+
"cli_commit": "e6ab8fb3cc284cc7f3c2ca4dd77c4490ef980f76",
|
|
5
|
+
"built_at": "2026-09-20T10:58:21.835Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
8
|
"commit": "97f811d33c212ae3497020178b1ed825c7c3ebac",
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { createContext, createRouterContext } from "../runtime/context.js";
|
|
|
9
9
|
import { migrateStoreWriter } from "../storage/writer-migration.js";
|
|
10
10
|
import { helpForArgs } from "./help.js";
|
|
11
11
|
import { AppError, isAppError } from "../shared/errors.js";
|
|
12
|
+
import { entityReferenceVariants } from "../shared/entity-references.js";
|
|
12
13
|
import { writeJson } from "../shared/json.js";
|
|
13
14
|
import { archiveProject, prepareProjectArchive, getProjectStatus, migrateProjectIds, registerProject, resolveProject } from "../services/projects.js";
|
|
14
15
|
import { cancelProtocol, getProtocolBlockers, getProtocolStatus, getReadyProtocols, implementProtocol, readyForMerge, registerProtocol, prepareProtocolRegistration, requireProtocol, syncProtocolFromRun, prepareProtocolTransition, transitionProtocol } from "../services/protocols.js";
|
|
@@ -1561,7 +1562,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
1561
1562
|
registerProject(context, { root: projectRoot });
|
|
1562
1563
|
return findRecentMatchingHookEvent(context, {
|
|
1563
1564
|
projectId: requireProjectByRoot(context, resolveProjectRoot(projectRoot)).id,
|
|
1564
|
-
matchKey: stageStartMatchKey(
|
|
1565
|
+
matchKey: entityReferenceVariants(runId).map((reference) => stageStartMatchKey(reference, stage, projectRoot, contextSha256)),
|
|
1565
1566
|
errorCode: "trusted_session_binding_required",
|
|
1566
1567
|
operation: `vNext ${stage.toUpperCase()} start`
|
|
1567
1568
|
}).eventKey;
|
package/dist/services/hooks.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { parse, stringify, TomlError } from "smol-toml";
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { entityReferenceVariants } from "../shared/entity-references.js";
|
|
7
8
|
import { parseJsonObject } from "../shared/json.js";
|
|
8
9
|
import { canonicalPath, ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
9
10
|
import { appendAudit } from "./audit.js";
|
|
@@ -939,16 +940,25 @@ export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
|
|
|
939
940
|
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
940
941
|
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
941
942
|
}
|
|
943
|
+
function workLifecycleMatchKeys(operation, workId, projectRoot) {
|
|
944
|
+
return entityReferenceVariants(workId).map((reference) => workLifecycleMatchKey(operation, reference, projectRoot));
|
|
945
|
+
}
|
|
946
|
+
function stageLifecycleMatchKeys(operation, runId, stage, projectRoot, workId) {
|
|
947
|
+
const runReferences = entityReferenceVariants(runId);
|
|
948
|
+
const workReferences = workId ? entityReferenceVariants(workId) : [undefined];
|
|
949
|
+
return [...new Set(runReferences.flatMap((runReference) => workReferences.map((workReference) => stageLifecycleMatchKey(operation, runReference, stage, projectRoot, workReference))))];
|
|
950
|
+
}
|
|
942
951
|
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
943
952
|
* the physical Session that issued it. Work code performs the owner check. */
|
|
944
953
|
export function claimWorkLifecycleHookEvent(context, input) {
|
|
945
|
-
const
|
|
954
|
+
const matchKeys = workLifecycleMatchKeys(input.operation, input.workId, input.projectRoot);
|
|
946
955
|
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
947
|
-
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
956
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
948
957
|
}).eventKey;
|
|
949
958
|
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
950
|
-
if (!event?.session_id || event.status !== "observed" || event.match_key
|
|
951
|
-
|
|
959
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
960
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
961
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey, reason, observed_status: event?.status ?? null });
|
|
952
962
|
}
|
|
953
963
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
954
964
|
if (claimed.changes !== 1)
|
|
@@ -958,13 +968,14 @@ export function claimWorkLifecycleHookEvent(context, input) {
|
|
|
958
968
|
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
959
969
|
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
960
970
|
export function claimStageLifecycleHookEvent(context, input) {
|
|
961
|
-
const
|
|
971
|
+
const matchKeys = stageLifecycleMatchKeys(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
962
972
|
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
963
|
-
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
973
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
964
974
|
}).eventKey;
|
|
965
975
|
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
966
|
-
if (!event?.session_id || event.status !== "observed" || event.match_key
|
|
967
|
-
|
|
976
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
977
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
978
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey, reason, observed_status: event?.status ?? null });
|
|
968
979
|
}
|
|
969
980
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
970
981
|
if (claimed.changes !== 1)
|
|
@@ -1023,11 +1034,15 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
1023
1034
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
1024
1035
|
export function findRecentMatchingHookEvent(context, input) {
|
|
1025
1036
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
1037
|
+
const matchKeys = Array.isArray(input.matchKey) ? [...new Set(input.matchKey)] : [input.matchKey];
|
|
1038
|
+
const placeholders = matchKeys.map(() => "?").join(", ");
|
|
1026
1039
|
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
1027
|
-
WHERE project_id = ? AND match_key
|
|
1040
|
+
WHERE project_id = ? AND match_key IN (${placeholders}) AND status = 'observed' AND created_at >= ?
|
|
1041
|
+
AND NOT (COALESCE(json_extract(outcome_json, '$.effect'), '') = 'no_effect'
|
|
1042
|
+
AND COALESCE(json_extract(outcome_json, '$.disposition'), '') IN ('correctable', 'fatal'))
|
|
1028
1043
|
AND harness IN ('codex-desktop', 'antigravity-cli')
|
|
1029
1044
|
AND (? IS NULL OR session_id = ?)
|
|
1030
|
-
ORDER BY id DESC LIMIT 2`, [input.projectId,
|
|
1045
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, ...matchKeys, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
|
|
1031
1046
|
let events = find();
|
|
1032
1047
|
// Only the qualified non-rewriting transports may correlate the original
|
|
1033
1048
|
// command: Codex Desktop and Antigravity. New harnesses fail closed by default.
|
|
@@ -1037,10 +1052,10 @@ export function findRecentMatchingHookEvent(context, input) {
|
|
|
1037
1052
|
events = find();
|
|
1038
1053
|
}
|
|
1039
1054
|
if (events.length === 0 || !events[0]?.event_key) {
|
|
1040
|
-
throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery; managed commands are admitted from committed native identity, not from model-supplied invocation IDs or updatedInput.`, 1);
|
|
1055
|
+
throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery; managed commands are admitted from committed native identity, not from model-supplied invocation IDs or updatedInput.`, 1, { reason: "event_missing" });
|
|
1041
1056
|
}
|
|
1042
1057
|
if (events.length > 1) {
|
|
1043
|
-
throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event;
|
|
1058
|
+
throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; stop and let the controller reconcile native delivery`, 1, { reason: "multiple_live_events", match_keys: matchKeys, event_keys: events.map((event) => event.event_key) });
|
|
1044
1059
|
}
|
|
1045
1060
|
return { id: events[0].id, eventKey: events[0].event_key };
|
|
1046
1061
|
}
|
|
@@ -1065,9 +1080,9 @@ export function bootstrapMatchKey(input) {
|
|
|
1065
1080
|
})).digest("hex");
|
|
1066
1081
|
}
|
|
1067
1082
|
export function claimWorkStartHookEvent(context, input) {
|
|
1068
|
-
const expected =
|
|
1083
|
+
const expected = entityReferenceVariants(input.workId).map((reference) => workStartMatchKey(reference, input.projectRoot, input.recoveryId));
|
|
1069
1084
|
const event = context.db.get("SELECT id, session_id, agent_id, turn_id, transcript_path, model, agent_type, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
1070
|
-
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.
|
|
1085
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
1071
1086
|
throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
|
|
1072
1087
|
}
|
|
1073
1088
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
@@ -1075,13 +1090,12 @@ export function claimWorkStartHookEvent(context, input) {
|
|
|
1075
1090
|
throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
|
|
1076
1091
|
return hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
1077
1092
|
}
|
|
1078
|
-
function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
1079
1093
|
/** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
|
|
1080
1094
|
export function assertStageStartHookEvent(context, input) {
|
|
1081
|
-
const expected =
|
|
1095
|
+
const expected = entityReferenceVariants(input.runId).map((reference) => stageStartMatchKey(reference, input.stage, input.projectRoot, input.contextSha256));
|
|
1082
1096
|
const event = context.db.get("SELECT session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
1083
|
-
if (!event?.session_id || event.status !== "observed" || event.match_key
|
|
1084
|
-
throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey,
|
|
1097
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
1098
|
+
throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey, expected_match_keys: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
|
|
1085
1099
|
}
|
|
1086
1100
|
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
1087
1101
|
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
@@ -1115,10 +1129,10 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
|
1115
1129
|
}
|
|
1116
1130
|
/** Claims the resume command observed in the Session that received the user answer. */
|
|
1117
1131
|
export function claimStageResumeHookEvent(context, input) {
|
|
1118
|
-
const
|
|
1119
|
-
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
|
|
1132
|
+
const matchKeys = entityReferenceVariants(input.runId).flatMap((runReference) => entityReferenceVariants(input.workId).map((workReference) => stageResumeMatchKey(runReference, input.stage, workReference, input.projectRoot)));
|
|
1133
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey: matchKeys, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
|
|
1120
1134
|
const event = context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
1121
|
-
if (!event?.event_key || !event.session_id || event.match_key
|
|
1135
|
+
if (!event?.event_key || !event.session_id || !event.match_key || !matchKeys.includes(event.match_key) || event.status !== "observed") {
|
|
1122
1136
|
throw new AppError("trusted_stage_resume_required", "stage resume requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, work_id: input.workId });
|
|
1123
1137
|
}
|
|
1124
1138
|
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
@@ -4,6 +4,8 @@ import fs from "node:fs";
|
|
|
4
4
|
import { quote } from "shell-quote";
|
|
5
5
|
import { setTimeout as delay } from "node:timers/promises";
|
|
6
6
|
import { AppError, errorRecord } from "../shared/errors.js";
|
|
7
|
+
import { shortEntityReference } from "../shared/entity-references.js";
|
|
8
|
+
import { resolveWorkReference } from "../storage/work-references.js";
|
|
7
9
|
import { commandOption, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
|
|
8
10
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
11
|
import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
|
|
@@ -73,10 +75,9 @@ function normalizedInvocationValue(key, value) {
|
|
|
73
75
|
if (runRelative)
|
|
74
76
|
return `@run/${runRelative}`;
|
|
75
77
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return /(?:^|\/)(RCP-\d{3,})$/.exec(value)?.[1] ?? value;
|
|
78
|
+
if (["positional", "work", "run"].includes(key))
|
|
79
|
+
return shortEntityReference(value);
|
|
80
|
+
return key === "retry-check" ? /(?:^|\/)(RCP-\d{3,})$/.exec(value)?.[1] ?? value : value;
|
|
80
81
|
}
|
|
81
82
|
function withoutInvocationId(command, aliases) {
|
|
82
83
|
const parsed = invocation(command);
|
|
@@ -88,6 +89,8 @@ function withoutInvocationId(command, aliases) {
|
|
|
88
89
|
const option = argv[index - 1]?.replace(/^--/, "");
|
|
89
90
|
if (option === "project-root")
|
|
90
91
|
argv[index] = "@project";
|
|
92
|
+
else if (option === "work" || option === "run")
|
|
93
|
+
argv[index] = shortEntityReference(argv[index]);
|
|
91
94
|
else if (option && aliases && ["result-file", "decision-file", "verification-file", "context-file"].includes(option) && path.isAbsolute(argv[index])) {
|
|
92
95
|
for (const [name, root] of [["run", aliases.run], ["workspace", aliases.workspace], ["project", aliases.project]]) {
|
|
93
96
|
if (!root)
|
|
@@ -526,10 +529,14 @@ export function assertLifecycleInvocationCurrent(context, scope, command) {
|
|
|
526
529
|
let workId;
|
|
527
530
|
if (parsed.operation.startsWith("work_")) {
|
|
528
531
|
const target = parsed.args.positional[0] ?? "";
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
532
|
+
try {
|
|
533
|
+
workId = resolveWorkReference(context, target, { projectId: project.id, runId: scope.runId }).work_id;
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
if (!(error instanceof AppError) || !["not_found", "ambiguous_work_alias"].includes(error.code))
|
|
537
|
+
throw error;
|
|
538
|
+
throw new AppError("invocation_scope_mismatch", "Work does not belong unambiguously to the issued RUN scope", 1);
|
|
539
|
+
}
|
|
533
540
|
}
|
|
534
541
|
else if (parsed.operation !== "session_register" && !parsed.args.options.has("bootstrap")) {
|
|
535
542
|
const target = parsed.args.positional[0];
|
|
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { entityReferenceVariants } from "../shared/entity-references.js";
|
|
6
7
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
8
9
|
import { appendFlowRunTimelineEvent, setFlowRunRecoveryPaused } from "./runs.js";
|
|
@@ -271,7 +272,7 @@ export function resumeRunRecovery(context, input, prepared = prepareRunRecoveryR
|
|
|
271
272
|
export function acceptRunRecovery(context, input) {
|
|
272
273
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
273
274
|
const run = requireRecoveryRun(context, project.id, input.runId);
|
|
274
|
-
const matchKey =
|
|
275
|
+
const matchKey = entityReferenceVariants(run.id).map((reference) => recoveryAcceptMatchKey(reference, input.recoveryId, run.project_root));
|
|
275
276
|
const eventKey = input.hookEventId ?? findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_recovery_acceptance_required", operation: "run recovery accept" }).eventKey;
|
|
276
277
|
const identity = hookSessionIdentity(context, project.id, eventKey);
|
|
277
278
|
const now = context.now();
|
|
@@ -281,7 +282,7 @@ export function acceptRunRecovery(context, input) {
|
|
|
281
282
|
const guard = recoveryGuard(context, project.id, run.id);
|
|
282
283
|
const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [input.recoveryId]);
|
|
283
284
|
const hook = context.db.get("SELECT status, match_key FROM hook_events WHERE id = ?", [identity.hookEventId]);
|
|
284
|
-
if (!guard || guard.recovery_id !== input.recoveryId || !binding || binding.session_id !== identity.sessionId || binding.harness !== identity.harness || binding.provider_session_id !== identity.providerSessionId || binding.daemon_id !== identity.daemonId || hook?.match_key
|
|
285
|
+
if (!guard || guard.recovery_id !== input.recoveryId || !binding || binding.session_id !== identity.sessionId || binding.harness !== identity.harness || binding.provider_session_id !== identity.providerSessionId || binding.daemon_id !== identity.daemonId || !hook?.match_key || !matchKey.includes(hook.match_key))
|
|
285
286
|
throw new AppError("recovery_binding_mismatch", "Recovery acknowledgement does not belong to the authorized physical Session and generation", 1);
|
|
286
287
|
packetReceipt = recoveryPacketReceipt(binding);
|
|
287
288
|
if (binding.harness === "zcode-acp") {
|
|
@@ -4,7 +4,9 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
|
|
7
8
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
9
|
+
import { resolveWorkReference } from "../storage/work-references.js";
|
|
8
10
|
import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
|
|
9
11
|
import { requireProjectByRoot } from "./projects.js";
|
|
10
12
|
import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
|
|
@@ -96,7 +98,7 @@ export function resumeStageAfterUser(context, input) {
|
|
|
96
98
|
const { projectRoot, project, run, work, stage, pause, workSession, evidence } = prepareStageResume(context, input);
|
|
97
99
|
const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, {
|
|
98
100
|
projectId: project.id,
|
|
99
|
-
matchKey:
|
|
101
|
+
matchKey: entityReferenceVariants(run.id).flatMap((runReference) => entityReferenceVariants(work.work_id).map((workReference) => stageResumeMatchKey(runReference, stage.stage, workReference, projectRoot))),
|
|
100
102
|
errorCode: "trusted_stage_resume_required",
|
|
101
103
|
operation: "stage resume"
|
|
102
104
|
}).eventKey;
|
|
@@ -155,7 +157,7 @@ export function resumeStageAfterUser(context, input) {
|
|
|
155
157
|
return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: pause.id, question_path: pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
|
|
156
158
|
}
|
|
157
159
|
export function stagePauseCommand(context, input) {
|
|
158
|
-
return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
|
|
160
|
+
return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${shortEntityReference(input.runId)} --stage ${input.stage} --work ${shortEntityReference(input.workId)} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
|
|
159
161
|
}
|
|
160
162
|
/**
|
|
161
163
|
* The only shell form allowed for an agent-owned HITL pause. Supplying a
|
|
@@ -181,7 +183,7 @@ Effect on scope or acceptance:
|
|
|
181
183
|
USER_QUESTION`;
|
|
182
184
|
}
|
|
183
185
|
function stageResumeCommand(context, input) {
|
|
184
|
-
return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
|
|
186
|
+
return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${shortEntityReference(input.runId)} --stage ${input.stage} --work ${shortEntityReference(input.workId)} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
|
|
185
187
|
}
|
|
186
188
|
export function flowCommand(context) {
|
|
187
189
|
const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
|
|
@@ -207,10 +209,7 @@ function requireRun(context, projectId, id) {
|
|
|
207
209
|
return rows[0];
|
|
208
210
|
}
|
|
209
211
|
function requireWork(context, projectId, runId, workId) {
|
|
210
|
-
|
|
211
|
-
if (!work)
|
|
212
|
-
throw new AppError("not_found", "Work does not belong to this RUN", 1, { run_id: runId, work_id: workId });
|
|
213
|
-
return work;
|
|
212
|
+
return resolveWorkReference(context, workId, { projectId, runId });
|
|
214
213
|
}
|
|
215
214
|
function requireStage(run, stage, status) {
|
|
216
215
|
let index;
|
|
@@ -3,6 +3,8 @@ import crypto from "node:crypto";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
|
|
7
|
+
import { resolveWorkReference } from "../storage/work-references.js";
|
|
6
8
|
import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageStartHookEvent, claimWorkLifecycleHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
|
|
7
9
|
import { validateSchema } from "./schema-validation.js";
|
|
8
10
|
import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
|
|
@@ -41,14 +43,15 @@ export function assertStageLifecycleOwner(context, input) {
|
|
|
41
43
|
if (!project)
|
|
42
44
|
throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
|
|
43
45
|
requireRun(context, project.id, input.runId);
|
|
46
|
+
const scopedWork = input.workId ? requireWork(context, input.workId, { projectId: project.id, runId: input.runId }) : undefined;
|
|
44
47
|
let owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id
|
|
45
48
|
FROM work_sessions ws
|
|
46
49
|
JOIN works w ON w.work_id = ws.work_id
|
|
47
50
|
JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
|
|
48
51
|
WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running'
|
|
49
52
|
AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
|
|
50
|
-
${
|
|
51
|
-
ORDER BY ws.created_at DESC LIMIT 1`,
|
|
53
|
+
${scopedWork ? "AND w.work_id = ?" : ""}
|
|
54
|
+
ORDER BY ws.created_at DESC LIMIT 1`, scopedWork ? [project.id, input.runId, input.stage, scopedWork.work_id] : [project.id, input.runId, input.stage]);
|
|
52
55
|
if (!owner && input.operation === "stage_finish" && ["code", "code-review"].includes(input.stage)) {
|
|
53
56
|
const row = context.db.get("SELECT run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
54
57
|
const index = row ? JSON.parse(row.index_json) : null;
|
|
@@ -371,14 +374,14 @@ export function deleteWork(context, id) {
|
|
|
371
374
|
prepareWorkDelete(context, id);
|
|
372
375
|
return context.db.writeTransaction(() => { const work = prepareWorkDelete(context, id); context.db.run("DELETE FROM works WHERE work_id = ?", [work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: work.work_id }; });
|
|
373
376
|
}
|
|
374
|
-
export function shortWorkId(id) { return
|
|
377
|
+
export function shortWorkId(id) { return shortEntityReference(id); }
|
|
375
378
|
/**
|
|
376
379
|
* Return the engine-bound command, never a PATH-dependent `dd-flow` token.
|
|
377
380
|
* Fan-out workers run in independently spawned harnesses where the adapter's
|
|
378
381
|
* PATH is not inherited; using the shared lifecycle command keeps them on the
|
|
379
382
|
* same captured runtime as their parent stage.
|
|
380
383
|
*/
|
|
381
|
-
export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return managedLifecycleCommand(context, `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)}${recovery ? ` --recovery-id ${JSON.stringify(recovery.recovery_id)}` : ""} --json`); }
|
|
384
|
+
export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return managedLifecycleCommand(context, `${flowCommand(context)} work start ${shortWorkId(work.work_id)} --project-root ${JSON.stringify(run.project_root)}${recovery ? ` --recovery-id ${JSON.stringify(recovery.recovery_id)}` : ""} --json`); }
|
|
382
385
|
export function assertWorkLaunchReady(context, id) {
|
|
383
386
|
ensureWorkRegistry(context);
|
|
384
387
|
const work = requireWork(context, id);
|
|
@@ -409,15 +412,7 @@ export function startWork(context, id, input) {
|
|
|
409
412
|
const recoveryId = recoveryGuard(context, work.project_id, work.run_id)?.recovery_id;
|
|
410
413
|
if (input.recoveryId !== recoveryId)
|
|
411
414
|
throw new AppError("run_recovery_generation_stale", "Work launch must use the exact current recovery packet", 1, { work_id: id, recovery_id: recoveryId ?? null });
|
|
412
|
-
|
|
413
|
-
if (!hookEventId) {
|
|
414
|
-
try {
|
|
415
|
-
hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(shortWorkId(work.work_id), run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
|
|
416
|
-
}
|
|
417
|
-
catch {
|
|
418
|
-
hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
415
|
+
const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: entityReferenceVariants(work.work_id).map((reference) => workStartMatchKey(reference, run.project_root, recoveryId)), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
|
|
421
416
|
const eventKey = hookEventId;
|
|
422
417
|
const started = startBoundWork(context, work, run, () => claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) }), eventKey);
|
|
423
418
|
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, work_session_id: started.work_session_id });
|
|
@@ -980,7 +975,7 @@ function renderWorkerPrompt(context, work, run, dependencies) {
|
|
|
980
975
|
const command = flowCommand(context);
|
|
981
976
|
const packet = codePacket(work);
|
|
982
977
|
const finishWorkCommand = managedLifecycleCommand(context, `${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`);
|
|
983
|
-
const failWorkCommand = managedLifecycleCommand(context, `${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`);
|
|
978
|
+
const failWorkCommand = managedLifecycleCommand(context, `${command} work fail ${shortWorkId(work.work_id)} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`);
|
|
984
979
|
const mergeWork = parsePayload(work)?.kind === "merge";
|
|
985
980
|
const writeBoundary = mergeWork
|
|
986
981
|
? ["MERGE does not own product-code repair. Use only the stage packet's merge apply command; resolve only paths that Git reports as unmerged. Do not edit integration code, tests, documentation or configuration to make a gate pass.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result."]
|
|
@@ -1112,22 +1107,13 @@ function codeReviewGroup(work) {
|
|
|
1112
1107
|
? { key: value.key, aspect_ids: value.aspect_ids }
|
|
1113
1108
|
: null;
|
|
1114
1109
|
}
|
|
1115
|
-
function requireWork(context, id) {
|
|
1110
|
+
function requireWork(context, id, explicitScope) {
|
|
1116
1111
|
ensureWorkRegistry(context);
|
|
1117
1112
|
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
1118
1113
|
const scope = configured ? JSON.parse(configured) : undefined;
|
|
1119
|
-
const projectId = scope?.projectRoot ? context.db.get("SELECT id FROM projects WHERE root = ?", [scope.projectRoot])?.id : undefined;
|
|
1120
|
-
const
|
|
1121
|
-
|
|
1122
|
-
const exact = context.db.get(`SELECT ${workColumns} FROM works WHERE work_id = ?${scoped}`, [id, ...params]);
|
|
1123
|
-
if (exact)
|
|
1124
|
-
return exact;
|
|
1125
|
-
if (!/^WRK-\d{3,}$/.test(id))
|
|
1126
|
-
throw new AppError("not_found", "Work is not registered in the current RUN", 1, { work_id: id });
|
|
1127
|
-
const matches = context.db.all(`SELECT ${workColumns} FROM works WHERE work_id LIKE ?${scoped} ORDER BY work_id`, [`${id}-%`, ...params]);
|
|
1128
|
-
if (matches.length !== 1)
|
|
1129
|
-
throw new AppError(matches.length ? "ambiguous_work_alias" : "not_found", matches.length ? "Short Work alias is ambiguous in the current RUN" : "Work is not registered in the current RUN", 1, { work_id: id, matches: matches.map((work) => work.work_id) });
|
|
1130
|
-
return matches[0];
|
|
1114
|
+
const projectId = explicitScope?.projectId ?? (scope?.projectRoot ? context.db.get("SELECT id FROM projects WHERE root = ?", [scope.projectRoot])?.id : undefined);
|
|
1115
|
+
const runId = explicitScope?.runId ?? scope?.runId;
|
|
1116
|
+
return resolveWorkReference(context, id, { projectId, runId });
|
|
1131
1117
|
}
|
|
1132
1118
|
function requireRun(context, projectId, runId) { const run = context.db.get("SELECT r.id, r.run_root, r.workspace_root, p.root AS project_root FROM runs r JOIN projects p ON p.id = r.project_id WHERE r.project_id = ? AND r.id = ?", [projectId, runId]); if (!run?.run_root)
|
|
1133
1119
|
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId }); return run; }
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
/** Read-only lookup; scope applies to exact IDs as well as short aliases. */
|
|
3
|
+
export function resolveWorkReference(context, id, scope = {}) {
|
|
4
|
+
const clauses = ["1 = 1"];
|
|
5
|
+
const params = [];
|
|
6
|
+
if (scope.projectId) {
|
|
7
|
+
clauses.push("project_id = ?");
|
|
8
|
+
params.push(scope.projectId);
|
|
9
|
+
}
|
|
10
|
+
if (scope.runId) {
|
|
11
|
+
clauses.push("run_id = ?");
|
|
12
|
+
params.push(scope.runId);
|
|
13
|
+
}
|
|
14
|
+
const where = clauses.join(" AND ");
|
|
15
|
+
const exact = context.db.get(`SELECT * FROM works WHERE ${where} AND work_id = ?`, [...params, id]);
|
|
16
|
+
if (exact)
|
|
17
|
+
return exact;
|
|
18
|
+
const matches = /^WRK-\d{3,}$/.test(id)
|
|
19
|
+
? context.db.all(`SELECT * FROM works WHERE ${where} AND work_id LIKE ? ORDER BY work_id`, [...params, `${id}-%`]) : [];
|
|
20
|
+
if (matches.length !== 1)
|
|
21
|
+
throw new AppError(matches.length ? "ambiguous_work_alias" : "not_found", matches.length ? "Short Work alias is ambiguous in the current RUN" : "Work is not registered in the current RUN", 1, { work_id: id, matches: matches.map(work => work.work_id) });
|
|
22
|
+
return matches[0];
|
|
23
|
+
}
|