@kontourai/flow-agents 3.7.0 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/build/src/builder-flow-runtime.js +118 -17
- package/build/src/cli/assignment-provider.js +13 -1
- package/build/src/cli/continuation-adapter.d.ts +24 -0
- package/build/src/cli/continuation-adapter.js +225 -0
- package/build/src/cli/workflow-sidecar.js +29 -11
- package/build/src/cli/workflow.js +67 -11
- package/build/src/continuation-driver.d.ts +92 -0
- package/build/src/continuation-driver.js +444 -0
- package/build/src/index.d.ts +2 -0
- package/build/src/index.js +1 -0
- package/build/src/tools/build-universal-bundles.js +53 -3
- package/docs/getting-started.md +9 -1
- package/docs/public-workflow-cli.md +54 -0
- package/docs/spec/builder-flow-runtime.md +36 -2
- package/docs/workflow-usage-guide.md +7 -0
- package/evals/integration/test_builder_step_producers.sh +37 -4
- package/evals/integration/test_bundle_install.sh +108 -4
- package/evals/integration/test_bundle_lifecycle.sh +4 -6
- package/evals/integration/test_install_merge.sh +18 -8
- package/evals/integration/test_public_workflow_cli.sh +11 -5
- package/evals/static/test_universal_bundles.sh +44 -1
- package/package.json +4 -4
- package/packaging/README.md +1 -1
- package/scripts/README.md +1 -1
- package/scripts/install-codex-home.sh +63 -23
- package/scripts/install-owned-files.js +18 -2
- package/src/builder-flow-runtime.ts +108 -10
- package/src/cli/assignment-provider.ts +13 -1
- package/src/cli/builder-flow-runtime.test.mjs +378 -20
- package/src/cli/continuation-adapter.ts +239 -0
- package/src/cli/continuation-driver.test.mjs +564 -0
- package/src/cli/workflow-sidecar.ts +27 -11
- package/src/cli/workflow.ts +68 -11
- package/src/continuation-driver.ts +532 -0
- package/src/index.ts +20 -0
- package/src/tools/build-universal-bundles.ts +51 -3
|
@@ -16,14 +16,21 @@ Options:
|
|
|
16
16
|
--console-token-file PATH
|
|
17
17
|
Read Console telemetry bearer token from a file.
|
|
18
18
|
--console-tenant ID Persist Console tenant identifier.
|
|
19
|
+
--skills-dir PATH Install portable skills here (default: $HOME/.agents/skills).
|
|
19
20
|
EOF
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
DEST="${CODEX_HOME:-$HOME/.codex}"
|
|
24
|
+
SKILLS_DIR="${FLOW_AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
|
|
23
25
|
DEST_SET=0
|
|
24
26
|
CONSOLE_CONFIG_ARGS=()
|
|
25
27
|
while [[ $# -gt 0 ]]; do
|
|
26
28
|
case "$1" in
|
|
29
|
+
--skills-dir)
|
|
30
|
+
[[ $# -ge 2 ]] || { echo "install-codex-home.sh: $1 requires a value" >&2; exit 2; }
|
|
31
|
+
SKILLS_DIR="$2"
|
|
32
|
+
shift 2
|
|
33
|
+
;;
|
|
27
34
|
--telemetry-sink|--telemetry-sinks|--console-url|--console-endpoint|--console-endpoint-url|--console-token-file|--console-tenant|--console-tenant-id)
|
|
28
35
|
[[ $# -ge 2 ]] || { echo "install-codex-home.sh: $1 requires a value" >&2; exit 2; }
|
|
29
36
|
CONSOLE_CONFIG_ARGS+=("$1" "$2")
|
|
@@ -69,9 +76,26 @@ NODE
|
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
[[ ! -L "$DEST" ]] || { echo "install-codex-home.sh: refusing symlink destination root: $DEST" >&2; exit 1; }
|
|
79
|
+
[[ ! -L "$SKILLS_DIR" ]] || { echo "install-codex-home.sh: refusing symlink skills destination root: $SKILLS_DIR" >&2; exit 1; }
|
|
72
80
|
ROOT_REAL="$(canonicalize_path "$ROOT_DIR")"
|
|
73
81
|
BUNDLE_SOURCE_REAL="$(canonicalize_path "$ROOT_DIR/dist/codex")"
|
|
74
82
|
DEST_REAL="$(canonicalize_path "$DEST")"
|
|
83
|
+
SKILLS_DIR_REAL="$(canonicalize_path "$SKILLS_DIR")"
|
|
84
|
+
if ! node - "$SKILLS_DIR" <<'NODE'
|
|
85
|
+
const fs = require("node:fs"); const path = require("node:path");
|
|
86
|
+
const absolute = path.resolve(process.argv[2]);
|
|
87
|
+
let current = path.parse(absolute).root;
|
|
88
|
+
for (const part of absolute.slice(current.length).split(path.sep).filter(Boolean)) {
|
|
89
|
+
current = path.join(current, part);
|
|
90
|
+
// macOS exposes /tmp as the system-managed /private/tmp symlink. Treat that
|
|
91
|
+
// mount alias as trusted while rejecting caller-controlled components.
|
|
92
|
+
if (current !== "/tmp" && fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
NODE
|
|
95
|
+
then
|
|
96
|
+
echo "install-codex-home.sh: refusing symlink component in skills destination: $SKILLS_DIR" >&2
|
|
97
|
+
exit 1
|
|
98
|
+
fi
|
|
75
99
|
REAL_CODEX_HOME_REAL="$(canonicalize_path "$REAL_CODEX_HOME")"
|
|
76
100
|
case "$DEST_REAL/" in
|
|
77
101
|
"$ROOT_REAL/"*|"$BUNDLE_SOURCE_REAL/"*)
|
|
@@ -85,6 +109,17 @@ case "$ROOT_REAL/" in
|
|
|
85
109
|
exit 1
|
|
86
110
|
;;
|
|
87
111
|
esac
|
|
112
|
+
case "$SKILLS_DIR_REAL/" in
|
|
113
|
+
"$ROOT_REAL/"*|"$BUNDLE_SOURCE_REAL/"*) echo "install-codex-home.sh: skills destination overlaps Flow Agents source: $SKILLS_DIR_REAL" >&2; exit 1 ;;
|
|
114
|
+
esac
|
|
115
|
+
case "$ROOT_REAL/" in
|
|
116
|
+
"$SKILLS_DIR_REAL/"*) echo "install-codex-home.sh: Flow Agents source overlaps skills destination: $SKILLS_DIR_REAL" >&2; exit 1 ;;
|
|
117
|
+
esac
|
|
118
|
+
case "$BUNDLE_SOURCE_REAL/" in
|
|
119
|
+
"$SKILLS_DIR_REAL/"*) echo "install-codex-home.sh: generated bundle source overlaps skills destination: $SKILLS_DIR_REAL" >&2; exit 1 ;;
|
|
120
|
+
esac
|
|
121
|
+
SKILLS_PATH_ROOT="$(node -e 'process.stdout.write(require("node:path").parse(process.argv[1]).root)' "$SKILLS_DIR_REAL")"
|
|
122
|
+
[[ "$SKILLS_DIR_REAL" != "$SKILLS_PATH_ROOT" ]] || { echo "install-codex-home.sh: refusing filesystem-root skills destination: $SKILLS_DIR_REAL" >&2; exit 1; }
|
|
88
123
|
|
|
89
124
|
if command -v npm >/dev/null 2>&1; then
|
|
90
125
|
(cd "$ROOT_DIR" && FLOW_AGENTS_EXPORT_DIAGNOSTICS=0 npm run build:bundles --silent >/dev/null)
|
|
@@ -93,10 +128,6 @@ else
|
|
|
93
128
|
exit 1
|
|
94
129
|
fi
|
|
95
130
|
|
|
96
|
-
mkdir -p "$DEST"
|
|
97
|
-
DEST="$(cd "$DEST" && pwd -P)"
|
|
98
|
-
DEST_REAL="$DEST"
|
|
99
|
-
|
|
100
131
|
assert_safe_dest_path() {
|
|
101
132
|
local rel="$1"
|
|
102
133
|
local current="$DEST"
|
|
@@ -122,22 +153,17 @@ assert_safe_dest_path() {
|
|
|
122
153
|
esac
|
|
123
154
|
}
|
|
124
155
|
|
|
125
|
-
# Stash the user's existing hooks.json (if any), so the merge step below can
|
|
126
|
-
# preserve user hooks across installs while replacing Flow Agents-managed groups.
|
|
127
156
|
FA_USER_HOOKS_STASH=""
|
|
128
|
-
assert_safe_dest_path "hooks.json"
|
|
129
|
-
if [[ -f "$DEST/hooks.json" ]]; then
|
|
130
|
-
FA_USER_HOOKS_STASH="$(mktemp /tmp/fa-user-hooks.XXXXXX.json)"
|
|
131
|
-
cp "$DEST/hooks.json" "$FA_USER_HOOKS_STASH"
|
|
132
|
-
fi
|
|
133
157
|
|
|
134
158
|
# A real Codex home can contain user-owned files in every shared directory.
|
|
135
159
|
# Prepare an exact Flow Agents overlay, then synchronize only files recorded in
|
|
136
160
|
# the ownership manifest. The synchronizer never uses directory-wide --delete:
|
|
137
161
|
# it removes only unchanged files from its prior manifest and refuses collisions.
|
|
138
162
|
FA_OWNED_OVERLAY="$(mktemp -d /tmp/fa-codex-overlay.XXXXXX)"
|
|
163
|
+
FA_SKILLS_OVERLAY="$(mktemp -d /tmp/fa-codex-skills-overlay.XXXXXX)"
|
|
139
164
|
cleanup_install_temps() {
|
|
140
165
|
rm -rf "$FA_OWNED_OVERLAY"
|
|
166
|
+
rm -rf "$FA_SKILLS_OVERLAY"
|
|
141
167
|
[[ -z "${FA_USER_HOOKS_STASH:-}" ]] || rm -f "$FA_USER_HOOKS_STASH"
|
|
142
168
|
}
|
|
143
169
|
trap cleanup_install_temps EXIT
|
|
@@ -157,22 +183,14 @@ for managed_dir in \
|
|
|
157
183
|
scripts
|
|
158
184
|
do
|
|
159
185
|
if [[ -d "$ROOT_DIR/dist/codex/$managed_dir" ]]; then
|
|
160
|
-
assert_safe_dest_path "$managed_dir"
|
|
161
186
|
mkdir -p "$FA_OWNED_OVERLAY/$managed_dir"
|
|
162
187
|
rsync -a "$ROOT_DIR/dist/codex/$managed_dir/" "$FA_OWNED_OVERLAY/$managed_dir/"
|
|
163
188
|
fi
|
|
164
189
|
done
|
|
165
190
|
|
|
166
|
-
#
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
mkdir -p "$FA_OWNED_OVERLAY/skills"
|
|
170
|
-
rsync -a "$ROOT_DIR/dist/codex/skills/" "$FA_OWNED_OVERLAY/skills/"
|
|
171
|
-
fi
|
|
172
|
-
|
|
173
|
-
if [[ -d "$ROOT_DIR/dist/codex/.codex/skills" ]]; then
|
|
174
|
-
mkdir -p "$FA_OWNED_OVERLAY/skills"
|
|
175
|
-
rsync -a "$ROOT_DIR/dist/codex/.codex/skills/" "$FA_OWNED_OVERLAY/skills/"
|
|
191
|
+
# Portable skills use Codex's universal catalog, independently of CODEX_HOME.
|
|
192
|
+
if [[ -d "$ROOT_DIR/dist/codex/.agents/skills" ]]; then
|
|
193
|
+
rsync -a "$ROOT_DIR/dist/codex/.agents/skills/" "$FA_SKILLS_OVERLAY/"
|
|
176
194
|
fi
|
|
177
195
|
|
|
178
196
|
if [[ -d "$ROOT_DIR/dist/codex/kits" ]]; then
|
|
@@ -193,10 +211,31 @@ for bundle_file in README.md console.telemetry.json install.sh; do
|
|
|
193
211
|
fi
|
|
194
212
|
done
|
|
195
213
|
|
|
196
|
-
|
|
214
|
+
# Check both destinations with the exact synchronizer before either can mutate.
|
|
215
|
+
node "$ROOT_DIR/scripts/install-owned-files.js" \
|
|
216
|
+
--check "$FA_SKILLS_OVERLAY" "$SKILLS_DIR_REAL" ".flow-agents/codex-universal-skills-install-manifest.json"
|
|
217
|
+
node "$ROOT_DIR/scripts/install-owned-files.js" \
|
|
218
|
+
--check "$FA_OWNED_OVERLAY" "$DEST_REAL" ".flow-agents/codex-install-manifest.json"
|
|
219
|
+
|
|
220
|
+
mkdir -p "$SKILLS_DIR"
|
|
221
|
+
SKILLS_DIR="$(cd "$SKILLS_DIR" && pwd -P)"
|
|
222
|
+
node "$ROOT_DIR/scripts/install-owned-files.js" \
|
|
223
|
+
"$FA_SKILLS_OVERLAY" "$SKILLS_DIR" ".flow-agents/codex-universal-skills-install-manifest.json"
|
|
224
|
+
|
|
225
|
+
mkdir -p "$DEST"
|
|
226
|
+
DEST="$(cd "$DEST" && pwd -P)"
|
|
227
|
+
DEST_REAL="$DEST"
|
|
197
228
|
node "$ROOT_DIR/scripts/install-owned-files.js" \
|
|
198
229
|
"$FA_OWNED_OVERLAY" "$DEST" ".flow-agents/codex-install-manifest.json"
|
|
199
230
|
|
|
231
|
+
# Stash the user's existing hooks.json (if any), so the merge step below can
|
|
232
|
+
# preserve user hooks across installs while replacing Flow Agents-managed groups.
|
|
233
|
+
assert_safe_dest_path "hooks.json"
|
|
234
|
+
if [[ -f "$DEST/hooks.json" ]]; then
|
|
235
|
+
FA_USER_HOOKS_STASH="$(mktemp /tmp/fa-user-hooks.XXXXXX.json)"
|
|
236
|
+
cp "$DEST/hooks.json" "$FA_USER_HOOKS_STASH"
|
|
237
|
+
fi
|
|
238
|
+
|
|
200
239
|
atomic_copy() {
|
|
201
240
|
local source="$1"
|
|
202
241
|
local rel="$2"
|
|
@@ -289,6 +328,7 @@ if [[ ${#CONSOLE_CONFIG_ARGS[@]} -gt 0 || -n "${FLOW_AGENTS_TELEMETRY_SINK:-}" |
|
|
|
289
328
|
fi
|
|
290
329
|
|
|
291
330
|
echo "Installed Flow Agents into Codex home at $DEST"
|
|
331
|
+
echo "Installed portable skills at $SKILLS_DIR"
|
|
292
332
|
if [[ "${#profile_names[@]}" -gt 0 ]]; then
|
|
293
333
|
echo "Profiles: ${profile_names[*]}"
|
|
294
334
|
fi
|
|
@@ -9,10 +9,24 @@ function fail(message) {
|
|
|
9
9
|
process.exit(1);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const checkOnly = args[0] === "--check";
|
|
14
|
+
if (checkOnly) args.shift();
|
|
15
|
+
const [sourceArg, destArg, manifestArg] = args;
|
|
13
16
|
if (!sourceArg || !destArg || !manifestArg) fail("usage: install-owned-files.js <overlay> <destination> <manifest-relative-path>");
|
|
14
17
|
const source = fs.realpathSync(sourceArg);
|
|
15
|
-
|
|
18
|
+
function canonicalizeMissing(value) {
|
|
19
|
+
let current = path.resolve(value);
|
|
20
|
+
const missing = [];
|
|
21
|
+
while (!fs.existsSync(current)) {
|
|
22
|
+
missing.unshift(path.basename(current));
|
|
23
|
+
const parent = path.dirname(current);
|
|
24
|
+
if (parent === current) break;
|
|
25
|
+
current = parent;
|
|
26
|
+
}
|
|
27
|
+
return path.resolve(fs.realpathSync(current), ...missing);
|
|
28
|
+
}
|
|
29
|
+
const dest = canonicalizeMissing(destArg);
|
|
16
30
|
const manifestPath = path.join(dest, manifestArg);
|
|
17
31
|
|
|
18
32
|
function hashFile(file) {
|
|
@@ -132,6 +146,8 @@ for (const [rel] of previous) {
|
|
|
132
146
|
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) fail(`refusing to remove symlink replacing owned file: ${target}`);
|
|
133
147
|
}
|
|
134
148
|
|
|
149
|
+
if (checkOnly) process.exit(0);
|
|
150
|
+
|
|
135
151
|
for (const entry of incoming) {
|
|
136
152
|
const target = targetFor(entry.rel);
|
|
137
153
|
ensureSafeParent(target, true);
|
|
@@ -367,10 +367,21 @@ async function syncAndProject(
|
|
|
367
367
|
const snapshot = stageTrustBundleSnapshot(context);
|
|
368
368
|
try {
|
|
369
369
|
const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
|
|
370
|
-
const gateEvidence = await bundleGateEvidence(
|
|
370
|
+
const gateEvidence = await bundleGateEvidence(
|
|
371
|
+
rawBundle,
|
|
372
|
+
gates[0]!,
|
|
373
|
+
run.state,
|
|
374
|
+
run.state.subject,
|
|
375
|
+
context.projectRoot,
|
|
376
|
+
manifestEvidence(run.manifest),
|
|
377
|
+
);
|
|
371
378
|
if (gateEvidence) {
|
|
372
379
|
const alreadyAttached = manifestEvidence(run.manifest).some((entry) =>
|
|
373
|
-
entry.gate_id === gates[0]!.id
|
|
380
|
+
entry.gate_id === gates[0]!.id
|
|
381
|
+
&& entry.sha256 === snapshot.sha256
|
|
382
|
+
&& typeof entry.superseded_by !== "string"
|
|
383
|
+
&& timestampAtOrAfter(entry.attached_at, gateEvidence.visitEnteredAt)
|
|
384
|
+
&& gateEvidence.expectationIds.every((expectationId) => Array.isArray(entry.expectation_ids) && entry.expectation_ids.includes(expectationId))
|
|
374
385
|
);
|
|
375
386
|
if (!alreadyAttached) {
|
|
376
387
|
const supersede = manifestEvidence(run.manifest)
|
|
@@ -386,6 +397,7 @@ async function syncAndProject(
|
|
|
386
397
|
...(supersede.length > 0 ? { supersede } : {}),
|
|
387
398
|
...(gateEvidence.failed ? { status: "failed" } : {}),
|
|
388
399
|
...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
|
|
400
|
+
expectationIds: gateEvidence.expectationIds,
|
|
389
401
|
},
|
|
390
402
|
});
|
|
391
403
|
attached = true;
|
|
@@ -478,21 +490,72 @@ function openGatesForResult(run: BuilderFlowRunResult): Array<FlowGate & { id: s
|
|
|
478
490
|
) as Array<FlowGate & { id: string }>;
|
|
479
491
|
}
|
|
480
492
|
|
|
481
|
-
async function bundleGateEvidence(
|
|
493
|
+
async function bundleGateEvidence(
|
|
494
|
+
bundle: unknown,
|
|
495
|
+
gate: FlowGate,
|
|
496
|
+
state: FlowRunState,
|
|
497
|
+
subject: string,
|
|
498
|
+
projectRoot: string,
|
|
499
|
+
manifest: AnyRecord[],
|
|
500
|
+
): Promise<{ failed: boolean; routeReason: string | null; expectationIds: string[]; visitEnteredAt: number } | null> {
|
|
482
501
|
if (!isRecord(bundle) || !Array.isArray(bundle.claims)) return null;
|
|
483
|
-
const
|
|
502
|
+
const expectations = expectationsForGate(gate) as FlowExpectation[];
|
|
503
|
+
const visit = currentGateVisit(state, String((gate as AnyRecord).step));
|
|
504
|
+
const enteredAt = visit.enteredAt;
|
|
505
|
+
const synchronizedAt = Date.now();
|
|
506
|
+
const maxClockSkewMs = 30_000;
|
|
507
|
+
const priorVisitClaimIds = new Set<string>();
|
|
508
|
+
const priorVisitEvidenceIds = new Set<string>();
|
|
509
|
+
for (const entry of manifest) {
|
|
510
|
+
if (entry.gate_id !== String((gate as AnyRecord).id)) continue;
|
|
511
|
+
const claims = isRecord(entry.bundle) && Array.isArray(entry.bundle.claims) ? entry.bundle.claims : [];
|
|
512
|
+
for (const historical of claims) {
|
|
513
|
+
if (isRecord(historical) && typeof historical.id === "string") priorVisitClaimIds.add(historical.id);
|
|
514
|
+
}
|
|
515
|
+
const evidence = isRecord(entry.bundle) && Array.isArray(entry.bundle.evidence) ? entry.bundle.evidence : [];
|
|
516
|
+
for (const historical of evidence) {
|
|
517
|
+
if (isRecord(historical) && typeof historical.id === "string") priorVisitEvidenceIds.add(historical.id);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const claimIsCurrent = (claim: AnyRecord): boolean => {
|
|
521
|
+
if (typeof claim.id !== "string" || priorVisitClaimIds.has(claim.id)) return false;
|
|
522
|
+
const timestamps: number[] = [];
|
|
523
|
+
const createdAt = parseTimestamp(claim.createdAt);
|
|
524
|
+
if (createdAt !== null) timestamps.push(createdAt);
|
|
525
|
+
if (Array.isArray((bundle as AnyRecord).evidence)) for (const evidence of (bundle as AnyRecord).evidence) {
|
|
526
|
+
if (!isRecord(evidence) || evidence.claimId !== claim.id) continue;
|
|
527
|
+
if (typeof evidence.id !== "string" || priorVisitEvidenceIds.has(evidence.id)) return false;
|
|
528
|
+
const observedAt = parseTimestamp(evidence.observedAt);
|
|
529
|
+
if (observedAt !== null) timestamps.push(observedAt);
|
|
530
|
+
}
|
|
531
|
+
const initialAcquisitionSkew = visit.initial && claim.claimType === "builder.pull-work.selected" ? maxClockSkewMs : 0;
|
|
532
|
+
return timestamps.some((timestamp) => timestamp >= enteredAt - initialAcquisitionSkew
|
|
533
|
+
&& timestamp <= synchronizedAt + maxClockSkewMs);
|
|
534
|
+
};
|
|
484
535
|
const relevant = bundle.claims.filter((claim: unknown): claim is AnyRecord => {
|
|
485
536
|
if (!isRecord(claim)) return false;
|
|
486
|
-
|
|
537
|
+
if (claim.producerStatus === "superseded") return false;
|
|
538
|
+
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
539
|
+
if (metadata && typeof metadata.superseded_by === "string") return false;
|
|
540
|
+
return expectations.some((expectation) => {
|
|
541
|
+
const candidate = expectation.bundle_claim;
|
|
542
|
+
return candidate
|
|
543
|
+
&& claimIsCurrent(claim)
|
|
544
|
+
&&
|
|
487
545
|
candidate.claimType === claim.claimType
|
|
488
546
|
&& (!candidate.subjectType || candidate.subjectType === claim.subjectType)
|
|
489
|
-
);
|
|
547
|
+
});
|
|
490
548
|
});
|
|
491
549
|
if (relevant.length === 0) return null;
|
|
492
550
|
if (relevant.some((claim) => workflowSubjectRef(claim) !== subject)) {
|
|
493
551
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.workflow_subject_ref", "must match the persisted run subject");
|
|
494
552
|
}
|
|
495
553
|
const failed = relevant.some((claim) => claim.value === "fail" || claim.status === "disputed");
|
|
554
|
+
const expectationIds = expectations.filter((expectation) => relevant.some((claim: AnyRecord) => {
|
|
555
|
+
const selector = expectation.bundle_claim;
|
|
556
|
+
return selector.claimType === claim.claimType && (!selector.subjectType || selector.subjectType === claim.subjectType);
|
|
557
|
+
})).map((expectation) => expectation.id);
|
|
558
|
+
const missingRequired = expectations.filter((expectation) => expectation.required && !expectationIds.includes(expectation.id));
|
|
496
559
|
const routeReasons = [...new Set(relevant.flatMap((claim) => {
|
|
497
560
|
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
498
561
|
const gateClaim = metadata && isRecord(metadata.gate_claim) ? metadata.gate_claim : null;
|
|
@@ -502,6 +565,11 @@ async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: stri
|
|
|
502
565
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "must agree across current-gate claims");
|
|
503
566
|
}
|
|
504
567
|
const routeReason = routeReasons[0] ?? null;
|
|
568
|
+
if (failed && !routeReason) return null;
|
|
569
|
+
// Passing evidence waits for the complete expectation set. A failing
|
|
570
|
+
// snapshot is complete only when a gate producer explicitly declares its
|
|
571
|
+
// route reason; report-only disputed critique state remains pending.
|
|
572
|
+
if (!failed && missingRequired.length > 0) return null;
|
|
505
573
|
if (routeReason && !failed) {
|
|
506
574
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "requires failed current-gate evidence");
|
|
507
575
|
}
|
|
@@ -512,7 +580,31 @@ async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: stri
|
|
|
512
580
|
if (String((gate as AnyRecord).id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
|
|
513
581
|
await assertVerifiedTestsTrust(bundle.claims, projectRoot);
|
|
514
582
|
}
|
|
515
|
-
return { failed, routeReason };
|
|
583
|
+
return { failed, routeReason, expectationIds, visitEnteredAt: enteredAt };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function currentGateVisit(state: FlowRunState, step: string): { enteredAt: number; initial: boolean } {
|
|
587
|
+
let enteredAt: number | null = null;
|
|
588
|
+
for (const transition of state.transitions ?? []) {
|
|
589
|
+
if (transition.to_step !== step) continue;
|
|
590
|
+
const parsed = parseTimestamp(transition.at);
|
|
591
|
+
if (parsed !== null) enteredAt = parsed;
|
|
592
|
+
}
|
|
593
|
+
const initial = parseTimestamp(state.updated_at);
|
|
594
|
+
if (enteredAt !== null) return { enteredAt, initial: false };
|
|
595
|
+
if (initial !== null) return { enteredAt: initial, initial: true };
|
|
596
|
+
throw new BuilderBuildRunInputError("flow_run.state.updated_at", "must establish the current gate visit boundary");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function parseTimestamp(value: unknown): number | null {
|
|
600
|
+
if (typeof value !== "string") return null;
|
|
601
|
+
const parsed = Date.parse(value);
|
|
602
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function timestampAtOrAfter(value: unknown, boundary: number): boolean {
|
|
606
|
+
const parsed = parseTimestamp(value);
|
|
607
|
+
return parsed !== null && parsed >= boundary;
|
|
516
608
|
}
|
|
517
609
|
|
|
518
610
|
async function assertVerifiedTestsTrust(claims: unknown[], projectRoot: string): Promise<void> {
|
|
@@ -769,7 +861,9 @@ function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, side
|
|
|
769
861
|
const complete = run.state.status === "completed";
|
|
770
862
|
const paused = run.state.status === "paused";
|
|
771
863
|
const canceled = run.state.status === "canceled";
|
|
772
|
-
const
|
|
864
|
+
const needsDecision = run.state.status === "needs_decision";
|
|
865
|
+
const failed = run.state.status === "failed";
|
|
866
|
+
const action = complete || paused || canceled || needsDecision || failed ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
|
|
773
867
|
if (!action) {
|
|
774
868
|
throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
|
|
775
869
|
}
|
|
@@ -794,6 +888,10 @@ function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, side
|
|
|
794
888
|
? { status: "done", summary: "Canonical Flow run was canceled by an authorized external request. Artifacts are retained until separately archived." }
|
|
795
889
|
: paused
|
|
796
890
|
? { status: "blocked", summary: "Canonical Flow run is paused. The current assignment actor may resume it with a reason." }
|
|
891
|
+
: needsDecision
|
|
892
|
+
? { status: "blocked", summary: "Canonical Flow requires an external decision before continuation." }
|
|
893
|
+
: failed
|
|
894
|
+
? { status: "failed", summary: "Canonical Flow run failed; no continuation turn is allowed." }
|
|
797
895
|
: {
|
|
798
896
|
status: "continue",
|
|
799
897
|
summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
|
|
@@ -804,8 +902,8 @@ function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, side
|
|
|
804
902
|
const phase = phaseForStep(definition.phase_map, run.state.current_step) ?? sidecar.phase;
|
|
805
903
|
return {
|
|
806
904
|
...sidecar,
|
|
807
|
-
status: complete ? "delivered" : canceled ? "canceled" : paused ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
|
|
808
|
-
phase: complete || canceled ? "done" : phase,
|
|
905
|
+
status: complete ? "delivered" : canceled ? "canceled" : failed ? "failed" : (paused || needsDecision) ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
|
|
906
|
+
phase: complete || canceled || failed ? "done" : phase,
|
|
809
907
|
updated_at: run.state.updated_at,
|
|
810
908
|
flow_run: {
|
|
811
909
|
run_id: run.runId,
|
|
@@ -775,7 +775,19 @@ export function performLocalReleaseUnderLock(
|
|
|
775
775
|
// seam, relocated to this write path).
|
|
776
776
|
const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
|
|
777
777
|
const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
|
|
778
|
-
|
|
778
|
+
// Pre-3.7 lifecycle events could persist the derived ancestry actor before
|
|
779
|
+
// sanitizeSegment removed ':' separators. Modern explicit/env release paths
|
|
780
|
+
// always use the sanitized form. Accept only that one-way legacy migration;
|
|
781
|
+
// never normalize two modern keys or relax ownership to a prefix match.
|
|
782
|
+
const sameActorStruct = existing.actor.runtime === releasedBy.runtime
|
|
783
|
+
&& existing.actor.session_id === releasedBy.session_id
|
|
784
|
+
&& existing.actor.host === releasedBy.host
|
|
785
|
+
&& (existing.actor.human ?? null) === (releasedBy.human ?? null);
|
|
786
|
+
const legacyActorKeyMatches = holderActorKey.includes(":")
|
|
787
|
+
&& holderActorKey === helper.serializeActor(existing.actor)
|
|
788
|
+
&& helper.sanitizeSegment(holderActorKey) === releasedByActorKey
|
|
789
|
+
&& sameActorStruct;
|
|
790
|
+
if (holderActorKey !== releasedByActorKey && !legacyActorKeyMatches) {
|
|
779
791
|
if (tolerateNoActiveClaim) return null;
|
|
780
792
|
throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
|
|
781
793
|
}
|