@evoclock/pi-agentic-driver 0.8.4 → 0.9.1
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/README.md +55 -27
- package/extensions/herdr-communication.ts +8 -1
- package/extensions/herdr-lifecycle.ts +4 -3
- package/extensions/pulse.ts +74 -0
- package/package.json +6 -2
- package/scripts/enforcement/herdr_async_dispatch_pi.js +152 -18
- package/scripts/enforcement/herdr_communication_pi.js +42 -11
- package/scripts/enforcement/pulse_core_pi.js +268 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +920 -0
- package/scripts/enforcement/task_board_core_pi.js +1331 -3
- package/skills/pulse/SKILL.md +39 -0
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
15
15
|
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, openSync, closeSync, unlinkSync, chmodSync, statSync as fsStatSync } from "node:fs";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
16
17
|
import { dirname, join } from "node:path";
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
@@ -486,6 +487,10 @@ export function serializeObsidianCard(card) {
|
|
|
486
487
|
const checkbox = card.flags?.includes("cancelled") ? "[-]" : card.done ? "[x]" : "[ ]";
|
|
487
488
|
const parts = [checkbox, card.title];
|
|
488
489
|
parts.push(fieldText("id", card.cardId));
|
|
490
|
+
// §3.6: a live claim is published through the derived projection as the
|
|
491
|
+
// card being active. Presentation only — the claim record in dispatcher
|
|
492
|
+
// state is canonical for run authority, never this field.
|
|
493
|
+
if (card.activeClaim) parts.push(fieldText("active", card.activeClaim));
|
|
489
494
|
if (card.hash) parts.push(fieldText("hash", card.hash));
|
|
490
495
|
if (card.priority) parts.push(fieldText("priority", card.priority));
|
|
491
496
|
for (const flag of card.flags ?? []) parts.push(fieldText("flag", flag));
|
|
@@ -582,11 +587,19 @@ export function projectionPath(boardPath) {
|
|
|
582
587
|
// Recompute the projection from the canonical cards. Best-effort relative to
|
|
583
588
|
// the authoritative write: a projection failure is reported but never rolls
|
|
584
589
|
// back or invalidates the canonical persist.
|
|
585
|
-
export function writeProjection(boardPath, cards) {
|
|
590
|
+
export function writeProjection(boardPath, cards, claims = []) {
|
|
586
591
|
const path = projectionPath(boardPath);
|
|
587
592
|
try {
|
|
593
|
+
const claimedIds = new Set((Array.isArray(claims) ? claims : [])
|
|
594
|
+
.map((claim) => claim?.cardId)
|
|
595
|
+
.filter((id) => typeof id === "string"));
|
|
596
|
+
const annotated = cards.map((card) => {
|
|
597
|
+
if (!claimedIds.has(card.cardId)) return card;
|
|
598
|
+
const claim = (Array.isArray(claims) ? claims : []).find((entry) => entry?.cardId === card.cardId);
|
|
599
|
+
return { ...card, activeClaim: typeof claim?.role === "string" ? claim.role : "claimed" };
|
|
600
|
+
});
|
|
588
601
|
const frontmatter = "---\nkanban-plugin: board\n---\n\n";
|
|
589
|
-
const body = serializeBoard(
|
|
602
|
+
const body = serializeBoard(annotated, { surface: "obsidian" })
|
|
590
603
|
.replace(/^## backlog$/m, "## Backlog")
|
|
591
604
|
.replace(/^## in-progress$/m, "## In Progress")
|
|
592
605
|
.replace(/^## review$/m, "## Review")
|
|
@@ -632,13 +645,26 @@ function readWriterState(statePath) {
|
|
|
632
645
|
try {
|
|
633
646
|
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
634
647
|
const value = Number(state?.highWaterMark);
|
|
648
|
+
const generation = Number(state?.claimsGeneration);
|
|
635
649
|
return {
|
|
636
650
|
highWaterMark: Number.isInteger(value) && value >= 0 ? value : 0,
|
|
637
651
|
secret: typeof state?.secret === "string" && state.secret !== "" ? state.secret : null,
|
|
638
652
|
issuedCardIds: Array.isArray(state?.issuedCardIds) ? state.issuedCardIds.filter((id) => typeof id === "string") : [],
|
|
653
|
+
// Monotonic claims-generation anchor (deletion/rollback guard): the
|
|
654
|
+
// generation and digest of the LAST claims state this writer issued.
|
|
655
|
+
// A missing claims file is "fresh" only while no generation was ever
|
|
656
|
+
// issued; a lower generation or digest mismatch is replay/deletion.
|
|
657
|
+
claimsGeneration: Number.isInteger(generation) && generation >= 0 ? generation : 0,
|
|
658
|
+
claimsDigest: typeof state?.claimsDigest === "string" && state.claimsDigest !== "" ? state.claimsDigest : null,
|
|
659
|
+
// A complete next claims state is staged here before either authority
|
|
660
|
+
// file changes. Recovery always rolls it forward; it never guesses
|
|
661
|
+
// whether an older claims file is legitimate.
|
|
662
|
+
pendingClaimsState: state?.pendingClaimsState && typeof state.pendingClaimsState === "object"
|
|
663
|
+
? state.pendingClaimsState
|
|
664
|
+
: null,
|
|
639
665
|
};
|
|
640
666
|
} catch {
|
|
641
|
-
return { highWaterMark: 0, secret: null, issuedCardIds: [] };
|
|
667
|
+
return { highWaterMark: 0, secret: null, issuedCardIds: [], claimsGeneration: 0, claimsDigest: null, pendingClaimsState: null };
|
|
642
668
|
}
|
|
643
669
|
}
|
|
644
670
|
|
|
@@ -1254,6 +1280,1250 @@ export function isDispatchable(card, boardIndex) {
|
|
|
1254
1280
|
return { dispatchable: failed.length === 0, failedConditions: failed };
|
|
1255
1281
|
}
|
|
1256
1282
|
|
|
1283
|
+
// ---------------------------------------------------------------------------
|
|
1284
|
+
// Dispatcher state (§3.6, §4): claims and assignment envelopes live OUTSIDE
|
|
1285
|
+
// the Markdown, beside the board, under the same writer lock as card writes.
|
|
1286
|
+
// Claim creation, envelope creation, and active-state publication happen in
|
|
1287
|
+
// one atomic operation. The claims file is canonical for run authority; the
|
|
1288
|
+
// Markdown stays canonical for task semantics.
|
|
1289
|
+
// ---------------------------------------------------------------------------
|
|
1290
|
+
|
|
1291
|
+
export const CLAIMS_SCHEMA = "agentic-driver.board-claims.v2";
|
|
1292
|
+
export const ENVELOPE_SCHEMA = "agentic-driver.assignment-envelope.v1";
|
|
1293
|
+
export const AUTOMATION_POLICY_SCHEMA = "agentic-driver.automation-policy.v1";
|
|
1294
|
+
export const PLACEMENTS = Object.freeze(["container", "host"]);
|
|
1295
|
+
export const RISK_LEVELS = Object.freeze(["low", "medium", "high"]);
|
|
1296
|
+
export const DEFAULT_ENVELOPE_EXPIRY_HOURS = 12;
|
|
1297
|
+
|
|
1298
|
+
// F1: the claims file is AUTHENTICATED exactly like the writer state — an
|
|
1299
|
+
// HMAC-SHA256 over its content, keyed by the writer-state secret, verified on
|
|
1300
|
+
// every read. Malformed, tampered, or HMAC-failing claims files fail closed:
|
|
1301
|
+
// dispatch is REFUSED, never treated as an empty claims list.
|
|
1302
|
+
|
|
1303
|
+
export function claimsPath(boardPath) {
|
|
1304
|
+
return `${boardPath}.claims.json`;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
export function automationPolicyPath(boardPath) {
|
|
1308
|
+
return `${boardPath}.automation-policy.json`;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function readJsonFile(path) {
|
|
1312
|
+
try {
|
|
1313
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1314
|
+
} catch {
|
|
1315
|
+
return null;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function writeJsonFileAtomic(path, value) {
|
|
1320
|
+
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
1321
|
+
writeFileSync(tmpPath, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
1322
|
+
chmodSync(tmpPath, 0o600);
|
|
1323
|
+
renameSync(tmpPath, path);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// HMAC over the canonical JSON form of the claims content, keyed by the
|
|
1327
|
+
// writer-state secret (F1). Covers claims, consumed-claim records, and the
|
|
1328
|
+
// transaction record — every authority-bearing field of the file.
|
|
1329
|
+
function claimsFileHmac(value, secret) {
|
|
1330
|
+
return createHmac("sha256", secret)
|
|
1331
|
+
.update(canonicalJsonString({
|
|
1332
|
+
schema: value.schema,
|
|
1333
|
+
generation: value.generation,
|
|
1334
|
+
transaction: value.transaction ?? null,
|
|
1335
|
+
claims: value.claims ?? [],
|
|
1336
|
+
consumedClaims: value.consumedClaims ?? [],
|
|
1337
|
+
}), "utf8")
|
|
1338
|
+
.digest("hex");
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Item 6: CLOSED, strictly validated shapes. Every record trusted by the
|
|
1342
|
+
// dispatcher has an exact key set and typed/pattern-checked values — unknown
|
|
1343
|
+
// or missing keys fail closed before anything is trusted.
|
|
1344
|
+
const ISO_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
1345
|
+
|
|
1346
|
+
function exactKeys(value, keys) {
|
|
1347
|
+
const actual = Object.keys(value ?? {}).sort();
|
|
1348
|
+
const expected = [...keys].sort();
|
|
1349
|
+
return actual.length === expected.length && actual.every((key, i) => key === expected[i]);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const ENVELOPE_FIELDS = Object.freeze([
|
|
1353
|
+
"schema", "envelopeId", "cardId", "cardHash", "repository", "startingRevision",
|
|
1354
|
+
"baseRevision", "branch", "allowedPaths", "unchangedPaths", "capabilities",
|
|
1355
|
+
"stoppingPoint", "acceptance", "placement", "interactionProfile", "risk",
|
|
1356
|
+
"riskCeiling", "mode", "createdAt", "expiry",
|
|
1357
|
+
]);
|
|
1358
|
+
|
|
1359
|
+
export function wellFormedEnvelope(env) {
|
|
1360
|
+
if (env === null || typeof env !== "object" || Array.isArray(env) || !exactKeys(env, ENVELOPE_FIELDS)) return false;
|
|
1361
|
+
if (env.schema !== ENVELOPE_SCHEMA) return false;
|
|
1362
|
+
if (typeof env.envelopeId !== "string" || !/^[0-9a-f]{32}$/.test(env.envelopeId)) return false;
|
|
1363
|
+
if (typeof env.cardId !== "string" || !CARD_ID_RE.test(env.cardId)) return false;
|
|
1364
|
+
if (env.cardHash !== null && !/^[0-9a-f]{64}$/.test(env.cardHash)) return false;
|
|
1365
|
+
if (typeof env.repository !== "string" || env.repository === "") return false;
|
|
1366
|
+
if (env.startingRevision !== null && !COMMIT_SHA_RE.test(env.startingRevision)) return false;
|
|
1367
|
+
if (env.baseRevision !== null && !COMMIT_SHA_RE.test(env.baseRevision)) return false;
|
|
1368
|
+
if (typeof env.branch !== "string" || !/^board\//.test(env.branch)) return false;
|
|
1369
|
+
if (!Array.isArray(env.allowedPaths) || !env.allowedPaths.every((p) => SAFE_PATH_RE.test(p))) return false;
|
|
1370
|
+
if (!Array.isArray(env.unchangedPaths) || !env.unchangedPaths.every((p) => SAFE_PATH_RE.test(p))) return false;
|
|
1371
|
+
if (!Array.isArray(env.capabilities) || !env.capabilities.every((c) => CAPABILITY_NAME_RE.test(c))) return false;
|
|
1372
|
+
if (env.stoppingPoint !== null && typeof env.stoppingPoint !== "string") return false;
|
|
1373
|
+
if (env.acceptance === null || typeof env.acceptance !== "object" || !exactKeys(env.acceptance, ["specHash", "dodHash"])) return false;
|
|
1374
|
+
if (env.acceptance.specHash !== null && !/^[0-9a-f]{64}$/.test(env.acceptance.specHash)) return false;
|
|
1375
|
+
if (env.acceptance.dodHash !== null && !/^[0-9a-f]{64}$/.test(env.acceptance.dodHash)) return false;
|
|
1376
|
+
if (!PLACEMENTS.includes(env.placement)) return false;
|
|
1377
|
+
if (!PLACEMENTS.includes(env.interactionProfile)) return false;
|
|
1378
|
+
if (!RISK_LEVELS.includes(env.risk)) return false;
|
|
1379
|
+
if (env.riskCeiling !== null && !RISK_LEVELS.includes(env.riskCeiling)) return false;
|
|
1380
|
+
if (env.mode !== "automated") return false;
|
|
1381
|
+
if (!ISO_TS_RE.test(env.createdAt) || !ISO_TS_RE.test(env.expiry)) return false;
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const CLAIM_FIELDS = Object.freeze(["cardId", "claimedAt", "role", "envelopeId", "envelope"]);
|
|
1386
|
+
const CONSUMED_FIELDS = Object.freeze(["cardId", "envelopeId", "consumedAt", "reason"]);
|
|
1387
|
+
const CONSUMED_REASONS = Object.freeze([
|
|
1388
|
+
"expired", "reclaimed", "completed", "exhausted", "cancelled", "failed",
|
|
1389
|
+
"waiting-approval", "role-blocked", "worker-unresponsive",
|
|
1390
|
+
]);
|
|
1391
|
+
const TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase"]);
|
|
1392
|
+
const REPLACE_TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase", "oldEnvelopeId", "oldReason"]);
|
|
1393
|
+
const CLAIMS_STATE_FIELDS = Object.freeze(["schema", "generation", "transaction", "claims", "consumedClaims", "hmac"]);
|
|
1394
|
+
|
|
1395
|
+
function wellFormedClaim(claim) {
|
|
1396
|
+
return claim !== null && typeof claim === "object" && !Array.isArray(claim) && exactKeys(claim, CLAIM_FIELDS)
|
|
1397
|
+
&& CARD_ID_RE.test(claim.cardId)
|
|
1398
|
+
&& ISO_TS_RE.test(claim.claimedAt)
|
|
1399
|
+
&& ROLE_NAME_RE.test(claim.role)
|
|
1400
|
+
&& /^[0-9a-f]{32}$/.test(claim.envelopeId)
|
|
1401
|
+
&& wellFormedEnvelope(claim.envelope)
|
|
1402
|
+
&& claim.envelope.envelopeId === claim.envelopeId
|
|
1403
|
+
&& claim.envelope.cardId === claim.cardId;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function wellFormedConsumedClaim(claim) {
|
|
1407
|
+
return claim !== null && typeof claim === "object" && !Array.isArray(claim) && exactKeys(claim, CONSUMED_FIELDS)
|
|
1408
|
+
&& CARD_ID_RE.test(claim.cardId)
|
|
1409
|
+
&& /^[0-9a-f]{32}$/.test(claim.envelopeId)
|
|
1410
|
+
&& ISO_TS_RE.test(claim.consumedAt)
|
|
1411
|
+
&& CONSUMED_REASONS.includes(claim.reason);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function wellFormedTransaction(tx) {
|
|
1415
|
+
if (tx === null) return true;
|
|
1416
|
+
if (typeof tx !== "object" || Array.isArray(tx)) return false;
|
|
1417
|
+
if (tx.op === "claim") {
|
|
1418
|
+
return exactKeys(tx, TRANSACTION_FIELDS)
|
|
1419
|
+
&& CARD_ID_RE.test(tx.cardId)
|
|
1420
|
+
&& /^[0-9a-f]{32}$/.test(tx.envelopeId) && ISO_TS_RE.test(tx.at)
|
|
1421
|
+
&& tx.phase === "claims-written";
|
|
1422
|
+
}
|
|
1423
|
+
// §10.4 replace-attempt transaction: the pre-recorded replacement identity
|
|
1424
|
+
// plus the old envelope ID and terminal reason it replaces.
|
|
1425
|
+
if (tx.op === "replace-attempt") {
|
|
1426
|
+
return exactKeys(tx, REPLACE_TRANSACTION_FIELDS)
|
|
1427
|
+
&& CARD_ID_RE.test(tx.cardId)
|
|
1428
|
+
&& /^[0-9a-f]{32}$/.test(tx.envelopeId) && /^[0-9a-f]{32}$/.test(tx.oldEnvelopeId)
|
|
1429
|
+
&& ISO_TS_RE.test(tx.at)
|
|
1430
|
+
&& tx.phase === "claims-written"
|
|
1431
|
+
&& CONSUMED_REASONS.includes(tx.oldReason);
|
|
1432
|
+
}
|
|
1433
|
+
return false;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// Read and VERIFY the claims file. Missing file → fresh ONLY while the
|
|
1437
|
+
// authenticated writer state proves no claims generation was ever issued;
|
|
1438
|
+
// once a generation is anchored, deletion fails closed. A generation older
|
|
1439
|
+
// than the anchored one, or a digest that does not match the anchored latest
|
|
1440
|
+
// digest, is replay of older correctly signed state — rejected. Malformed
|
|
1441
|
+
// JSON, wrong shape, or an HMAC that does not verify → {ok: false, reason};
|
|
1442
|
+
// the caller MUST fail closed (refuse dispatch).
|
|
1443
|
+
export function readClaimsState(boardPath) {
|
|
1444
|
+
const path = claimsPath(boardPath);
|
|
1445
|
+
const writerState = readWriterState(writerStatePath(boardPath));
|
|
1446
|
+
if (writerState.pendingClaimsState !== null) {
|
|
1447
|
+
return { ok: false, recoverable: true, reason: "a staged claims-anchor transaction requires recovery under the writer lock (fails closed)" };
|
|
1448
|
+
}
|
|
1449
|
+
if (!existsSync(path)) {
|
|
1450
|
+
if (writerState.claimsDigest !== null || writerState.claimsGeneration > 0) {
|
|
1451
|
+
return { ok: false, reason: "the claims file is missing but the writer state anchors issued claims state — deletion is rejected (fails closed)" };
|
|
1452
|
+
}
|
|
1453
|
+
return { ok: true, missing: true, state: { generation: 0, transaction: null, claims: [], consumedClaims: [] } };
|
|
1454
|
+
}
|
|
1455
|
+
const value = readJsonFile(path);
|
|
1456
|
+
if (value === null || typeof value !== "object" || Array.isArray(value) || !exactKeys(value, CLAIMS_STATE_FIELDS)) {
|
|
1457
|
+
return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
|
|
1458
|
+
}
|
|
1459
|
+
if (!Array.isArray(value.claims) || !Array.isArray(value.consumedClaims)
|
|
1460
|
+
|| !Number.isInteger(value.generation) || value.generation < 0
|
|
1461
|
+
|| !wellFormedTransaction(value.transaction)
|
|
1462
|
+
|| !value.claims.every(wellFormedClaim)
|
|
1463
|
+
|| !value.consumedClaims.every(wellFormedConsumedClaim)) {
|
|
1464
|
+
return { ok: false, reason: "the claims file contains a malformed record (fails closed)" };
|
|
1465
|
+
}
|
|
1466
|
+
// F2 integrity: an ACTIVE claim may never reference a consumed envelope —
|
|
1467
|
+
// a consumed envelope cannot be reused; only a new envelopeId is valid.
|
|
1468
|
+
const consumedIds = new Set(value.consumedClaims.map((entry) => entry.envelopeId));
|
|
1469
|
+
if (value.claims.some((claim) => consumedIds.has(claim.envelopeId))) {
|
|
1470
|
+
return { ok: false, reason: "an active claim references a consumed envelope — the claims file is inconsistent (fails closed)" };
|
|
1471
|
+
}
|
|
1472
|
+
if (writerState.secret === null) {
|
|
1473
|
+
return { ok: false, reason: "writer state file has no secret to verify the claims file (fails closed)" };
|
|
1474
|
+
}
|
|
1475
|
+
if (typeof value.hmac !== "string" || value.hmac !== claimsFileHmac(value, writerState.secret)) {
|
|
1476
|
+
return { ok: false, reason: "the claims file HMAC does not verify — tampered or forged (fails closed)" };
|
|
1477
|
+
}
|
|
1478
|
+
// Deletion/rollback guard: the generation must be exactly the anchored
|
|
1479
|
+
// latest (older = replay even if correctly signed; newer = not ours).
|
|
1480
|
+
if (value.generation !== writerState.claimsGeneration) {
|
|
1481
|
+
return { ok: false, reason: `claims generation ${value.generation} does not match the anchored generation ${writerState.claimsGeneration} — deletion or replay is rejected (fails closed)` };
|
|
1482
|
+
}
|
|
1483
|
+
if (writerState.claimsDigest !== claimsAnchorDigest(value, writerState.secret)) {
|
|
1484
|
+
return { ok: false, reason: "the claims content does not match the writer-state anchor — rollback is rejected (fails closed)" };
|
|
1485
|
+
}
|
|
1486
|
+
return {
|
|
1487
|
+
ok: true,
|
|
1488
|
+
state: {
|
|
1489
|
+
generation: value.generation,
|
|
1490
|
+
transaction: value.transaction,
|
|
1491
|
+
claims: value.claims,
|
|
1492
|
+
consumedClaims: value.consumedClaims,
|
|
1493
|
+
},
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Convenience reader. Throws a coded error on corruption so a caller cannot
|
|
1498
|
+
// silently treat tampered state as an empty claims list (Sol minor 3).
|
|
1499
|
+
export function readClaims(boardPath) {
|
|
1500
|
+
const result = readClaimsState(boardPath);
|
|
1501
|
+
if (!result.ok) {
|
|
1502
|
+
throw Object.assign(new Error(result.reason), { code: "claims-corrupt" });
|
|
1503
|
+
}
|
|
1504
|
+
return result.state.claims;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function claimsAnchorDigest(value, secret) {
|
|
1508
|
+
return createHmac("sha256", secret)
|
|
1509
|
+
.update(canonicalJsonString({ generation: value.generation, hmac: value.hmac }), "utf8")
|
|
1510
|
+
.digest("hex");
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Recover the two-file claims/anchor commit. The complete authenticated next
|
|
1514
|
+
// claims state is staged in writer state first, so either crash window rolls
|
|
1515
|
+
// forward deterministically rather than accepting an older file.
|
|
1516
|
+
function recoverClaimsAnchorLocked(boardPath) {
|
|
1517
|
+
const statePath = writerStatePath(boardPath);
|
|
1518
|
+
const writerState = readWriterState(statePath);
|
|
1519
|
+
const pending = writerState.pendingClaimsState;
|
|
1520
|
+
if (pending === null) return { ok: true, recovered: false };
|
|
1521
|
+
if (writerState.secret === null || !pending || typeof pending !== "object"
|
|
1522
|
+
|| !exactKeys(pending, CLAIMS_STATE_FIELDS)
|
|
1523
|
+
|| pending.hmac !== claimsFileHmac(pending, writerState.secret)) {
|
|
1524
|
+
return { ok: false, reason: "the staged claims-anchor transaction is malformed or unauthenticated (fails closed)" };
|
|
1525
|
+
}
|
|
1526
|
+
writeJsonFileAtomic(claimsPath(boardPath), pending);
|
|
1527
|
+
writeWriterState(statePath, {
|
|
1528
|
+
...writerState,
|
|
1529
|
+
claimsGeneration: pending.generation,
|
|
1530
|
+
claimsDigest: claimsAnchorDigest(pending, writerState.secret),
|
|
1531
|
+
pendingClaimsState: null,
|
|
1532
|
+
});
|
|
1533
|
+
return { ok: true, recovered: true };
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function writeClaims(boardPath, claims, { consumedClaims = null, transaction = null } = {}) {
|
|
1537
|
+
const statePath = writerStatePath(boardPath);
|
|
1538
|
+
let writerState = readWriterState(statePath);
|
|
1539
|
+
if (writerState.secret === null) {
|
|
1540
|
+
throw Object.assign(new Error("writer state file has no secret (fails closed)"), { code: "writer-state-unavailable" });
|
|
1541
|
+
}
|
|
1542
|
+
const recovery = recoverClaimsAnchorLocked(boardPath);
|
|
1543
|
+
if (!recovery.ok) throw Object.assign(new Error(recovery.reason), { code: "claims-anchor-recovery-failed" });
|
|
1544
|
+
writerState = readWriterState(statePath);
|
|
1545
|
+
const previous = readClaimsState(boardPath);
|
|
1546
|
+
if (!previous.ok) throw Object.assign(new Error(previous.reason), { code: "claims-corrupt" });
|
|
1547
|
+
const value = {
|
|
1548
|
+
schema: CLAIMS_SCHEMA,
|
|
1549
|
+
generation: previous.state.generation + 1,
|
|
1550
|
+
transaction,
|
|
1551
|
+
claims,
|
|
1552
|
+
consumedClaims: consumedClaims ?? previous.state.consumedClaims,
|
|
1553
|
+
};
|
|
1554
|
+
value.hmac = claimsFileHmac(value, writerState.secret);
|
|
1555
|
+
// Prepare → claims → commit. Because prepare contains the complete signed
|
|
1556
|
+
// next value, recovery can safely roll forward after either later write.
|
|
1557
|
+
writeWriterState(statePath, { ...writerState, pendingClaimsState: value });
|
|
1558
|
+
writeJsonFileAtomic(claimsPath(boardPath), value);
|
|
1559
|
+
writeWriterState(statePath, {
|
|
1560
|
+
...writerState,
|
|
1561
|
+
claimsGeneration: value.generation,
|
|
1562
|
+
claimsDigest: claimsAnchorDigest(value, writerState.secret),
|
|
1563
|
+
pendingClaimsState: null,
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// The automation policy (§3.2, §4): an explicit, revocable record the USER
|
|
1568
|
+
// sets. No policy file = no overnight dispatch, ever (fails closed). Shape:
|
|
1569
|
+
// { roles: [...], placement: "container" | "host", maxConcurrent: N,
|
|
1570
|
+
// expiry: ISO yyyy-mm-dd (or full ISO timestamp), envelopeExpiryHours? }.
|
|
1571
|
+
export function readAutomationPolicy(boardPath, { configPath = null } = {}) {
|
|
1572
|
+
const path = configPath ?? automationPolicyPath(boardPath);
|
|
1573
|
+
const value = readJsonFile(path);
|
|
1574
|
+
if (value === null || typeof value !== "object") return null;
|
|
1575
|
+
return value;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// Validate the policy shape and currency. The shape is CLOSED (F5): unknown
|
|
1579
|
+
// fields fail closed with a clear reason. Roles must match ROLE_NAME_RE.
|
|
1580
|
+
// The policy is bound to the board file it applies to (F4) and carries a risk
|
|
1581
|
+
// ceiling (F4). Returns {ok, policy, reason}.
|
|
1582
|
+
const POLICY_FIELDS = Object.freeze([
|
|
1583
|
+
"roles", "placement", "maxConcurrent", "expiry", "envelopeExpiryHours",
|
|
1584
|
+
"board", "riskCeiling", "allowPerCardRiskOverride", "acceptedRepositories",
|
|
1585
|
+
"pulse",
|
|
1586
|
+
]);
|
|
1587
|
+
|
|
1588
|
+
// Pulse policy v1 (PULSE_DESIGN_v3 §3): one optional closed `pulse` object
|
|
1589
|
+
// inside the existing board-bound automation policy. No second policy store:
|
|
1590
|
+
// the pulse record is validated here, beside the shipped policy fields, and
|
|
1591
|
+
// a policy without `pulse` behaves exactly as before (Pulse disabled).
|
|
1592
|
+
const PULSE_FIELDS = Object.freeze([
|
|
1593
|
+
"enabled", "mode", "intervalSeconds", "fillOnStart", "routing",
|
|
1594
|
+
"stallTimeoutSeconds", "unattendedHostRiskAccepted",
|
|
1595
|
+
]);
|
|
1596
|
+
const PULSE_ROUTE_FIELDS = Object.freeze(["model", "maxConcurrent"]);
|
|
1597
|
+
const PULSE_MODES = Object.freeze(["interactive", "automated"]);
|
|
1598
|
+
const PULSE_MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
1599
|
+
|
|
1600
|
+
function pulseCapacityOk(value) {
|
|
1601
|
+
return Number.isInteger(value) && value >= 1 && value <= 32;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function checkPulseRouteEntry(entry, { seen }) {
|
|
1605
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)
|
|
1606
|
+
|| !exactKeys(entry, PULSE_ROUTE_FIELDS)) {
|
|
1607
|
+
return "each routing entry must have exactly {model, maxConcurrent}";
|
|
1608
|
+
}
|
|
1609
|
+
if (typeof entry.model !== "string" || !PULSE_MODEL_RE.test(entry.model)) {
|
|
1610
|
+
return `route model "${entry.model}" is not an exact provider/model identifier`;
|
|
1611
|
+
}
|
|
1612
|
+
if (seen.has(entry.model)) {
|
|
1613
|
+
return `model "${entry.model}" appears more than once for this role`;
|
|
1614
|
+
}
|
|
1615
|
+
seen.add(entry.model);
|
|
1616
|
+
if (!pulseCapacityOk(entry.maxConcurrent)) {
|
|
1617
|
+
return `route maxConcurrent for "${entry.model}" must be an integer from 1 through 32`;
|
|
1618
|
+
}
|
|
1619
|
+
return null;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// Validate the optional `pulse` object. Returns {ok, pulse, reason}. Fails
|
|
1623
|
+
// closed on unknown fields, duplicate models, routing roles absent from the
|
|
1624
|
+
// policy's declared roles, and invalid capacities.
|
|
1625
|
+
export function checkPulsePolicy(pulse, { roles = null } = {}) {
|
|
1626
|
+
if (pulse === null || pulse === undefined) return { ok: true, pulse: null, reason: null };
|
|
1627
|
+
if (typeof pulse !== "object" || Array.isArray(pulse) || !exactKeys(pulse, PULSE_FIELDS)) {
|
|
1628
|
+
return { ok: false, pulse: null, reason: "the automation policy pulse object has unknown or missing fields — the pulse shape is closed (fails closed)" };
|
|
1629
|
+
}
|
|
1630
|
+
if (typeof pulse.enabled !== "boolean") {
|
|
1631
|
+
return { ok: false, pulse: null, reason: "pulse.enabled is required and must be a boolean (fails closed)" };
|
|
1632
|
+
}
|
|
1633
|
+
if (!PULSE_MODES.includes(pulse.mode)) {
|
|
1634
|
+
return { ok: false, pulse: null, reason: `pulse.mode must be one of ${PULSE_MODES.join(", ")} (fails closed)` };
|
|
1635
|
+
}
|
|
1636
|
+
if (!Number.isInteger(pulse.intervalSeconds) || pulse.intervalSeconds < 10 || pulse.intervalSeconds > 86400) {
|
|
1637
|
+
return { ok: false, pulse: null, reason: "pulse.intervalSeconds must be an integer from 10 through 86400 (fails closed)" };
|
|
1638
|
+
}
|
|
1639
|
+
if (typeof pulse.fillOnStart !== "boolean") {
|
|
1640
|
+
return { ok: false, pulse: null, reason: "pulse.fillOnStart must be a boolean (fails closed)" };
|
|
1641
|
+
}
|
|
1642
|
+
if (pulse.routing === null || typeof pulse.routing !== "object" || Array.isArray(pulse.routing)) {
|
|
1643
|
+
return { ok: false, pulse: null, reason: "pulse.routing must be an object keyed by declared policy role (fails closed)" };
|
|
1644
|
+
}
|
|
1645
|
+
const declared = Array.isArray(roles) ? roles : [];
|
|
1646
|
+
for (const role of Object.keys(pulse.routing)) {
|
|
1647
|
+
if (!declared.includes(role)) {
|
|
1648
|
+
return { ok: false, pulse: null, reason: `pulse.routing declares role "${role}", which is absent from the policy roles (fails closed)` };
|
|
1649
|
+
}
|
|
1650
|
+
const entry = pulse.routing[role];
|
|
1651
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)
|
|
1652
|
+
|| !exactKeys(entry, ["preferred", "fallback", "maxConcurrent"])) {
|
|
1653
|
+
return { ok: false, pulse: null, reason: `pulse.routing["${role}"] must have exactly {preferred, fallback, maxConcurrent} (fails closed)` };
|
|
1654
|
+
}
|
|
1655
|
+
if (!Array.isArray(entry.preferred) || entry.preferred.length === 0) {
|
|
1656
|
+
return { ok: false, pulse: null, reason: `pulse.routing["${role}"].preferred must list at least one route (fails closed)` };
|
|
1657
|
+
}
|
|
1658
|
+
if (!Array.isArray(entry.fallback)) {
|
|
1659
|
+
return { ok: false, pulse: null, reason: `pulse.routing["${role}"].fallback must be a list (fails closed)` };
|
|
1660
|
+
}
|
|
1661
|
+
const seen = new Set();
|
|
1662
|
+
for (const route of entry.preferred) {
|
|
1663
|
+
const reason = checkPulseRouteEntry(route, { seen });
|
|
1664
|
+
if (reason) return { ok: false, pulse: null, reason: `pulse.routing["${role}"].preferred: ${reason} (fails closed)` };
|
|
1665
|
+
}
|
|
1666
|
+
for (const route of entry.fallback) {
|
|
1667
|
+
const reason = checkPulseRouteEntry(route, { seen });
|
|
1668
|
+
if (reason) return { ok: false, pulse: null, reason: `pulse.routing["${role}"].fallback: ${reason} (fails closed)` };
|
|
1669
|
+
}
|
|
1670
|
+
if (!pulseCapacityOk(entry.maxConcurrent)) {
|
|
1671
|
+
return { ok: false, pulse: null, reason: `pulse.routing["${role}"].maxConcurrent must be an integer from 1 through 32 (fails closed)` };
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
if (!Number.isInteger(pulse.stallTimeoutSeconds) || pulse.stallTimeoutSeconds < 30 || pulse.stallTimeoutSeconds > 86400) {
|
|
1675
|
+
return { ok: false, pulse: null, reason: "pulse.stallTimeoutSeconds must be an integer from 30 through 86400 (fails closed)" };
|
|
1676
|
+
}
|
|
1677
|
+
if (typeof pulse.unattendedHostRiskAccepted !== "boolean") {
|
|
1678
|
+
return { ok: false, pulse: null, reason: "pulse.unattendedHostRiskAccepted must be a boolean (fails closed)" };
|
|
1679
|
+
}
|
|
1680
|
+
return { ok: true, pulse, reason: null };
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
export function checkAutomationPolicy(policy, { now = null, boardPath = null } = {}) {
|
|
1684
|
+
const at = now ?? new Date().toISOString();
|
|
1685
|
+
if (policy === null || policy === undefined) {
|
|
1686
|
+
return { ok: false, reason: "no automation policy is set — automated dispatch is refused (fails closed)" };
|
|
1687
|
+
}
|
|
1688
|
+
if (typeof policy !== "object" || Array.isArray(policy)) {
|
|
1689
|
+
return { ok: false, reason: "the automation policy is malformed (fails closed)" };
|
|
1690
|
+
}
|
|
1691
|
+
for (const key of Object.keys(policy)) {
|
|
1692
|
+
if (!POLICY_FIELDS.includes(key)) {
|
|
1693
|
+
return { ok: false, reason: `the automation policy has an unknown field "${key}" — the policy shape is closed (fails closed)` };
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
if (!Array.isArray(policy.roles) || policy.roles.length === 0
|
|
1697
|
+
|| !policy.roles.every((role) => typeof role === "string" && ROLE_NAME_RE.test(role))) {
|
|
1698
|
+
return { ok: false, reason: `the automation policy declares no valid roles (every role must match ${ROLE_NAME_RE.source}) (fails closed)` };
|
|
1699
|
+
}
|
|
1700
|
+
if (!PLACEMENTS.includes(policy.placement)) {
|
|
1701
|
+
return { ok: false, reason: `the automation policy placement must be one of ${PLACEMENTS.join(", ")} (fails closed)` };
|
|
1702
|
+
}
|
|
1703
|
+
const maxConcurrent = Number(policy.maxConcurrent);
|
|
1704
|
+
if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) {
|
|
1705
|
+
return { ok: false, reason: "the automation policy maxConcurrent must be a positive integer (fails closed)" };
|
|
1706
|
+
}
|
|
1707
|
+
const expiry = typeof policy.expiry === "string" ? policy.expiry : null;
|
|
1708
|
+
if (expiry === null || Number.isNaN(Date.parse(expiry))) {
|
|
1709
|
+
return { ok: false, reason: "the automation policy has no valid expiry (fails closed)" };
|
|
1710
|
+
}
|
|
1711
|
+
if (Date.parse(expiry) <= Date.parse(at)) {
|
|
1712
|
+
return { ok: false, reason: "the automation policy has expired — new dispatches are refused until it is renewed" };
|
|
1713
|
+
}
|
|
1714
|
+
if (policy.envelopeExpiryHours !== undefined
|
|
1715
|
+
&& (!Number.isFinite(Number(policy.envelopeExpiryHours)) || Number(policy.envelopeExpiryHours) <= 0)) {
|
|
1716
|
+
return { ok: false, reason: "the automation policy envelopeExpiryHours must be a positive number (fails closed)" };
|
|
1717
|
+
}
|
|
1718
|
+
if (typeof policy.board !== "string" || policy.board === "") {
|
|
1719
|
+
return { ok: false, reason: "the automation policy does not name the board file it applies to (fails closed)" };
|
|
1720
|
+
}
|
|
1721
|
+
if (boardPath !== null && policy.board !== boardPath) {
|
|
1722
|
+
return { ok: false, reason: `the automation policy is bound to board "${policy.board}", not this board (fails closed)` };
|
|
1723
|
+
}
|
|
1724
|
+
if (typeof policy.riskCeiling !== "string" || !RISK_LEVELS.includes(policy.riskCeiling)) {
|
|
1725
|
+
return { ok: false, reason: `the automation policy riskCeiling must be one of ${RISK_LEVELS.join(", ")} (fails closed)` };
|
|
1726
|
+
}
|
|
1727
|
+
if (policy.allowPerCardRiskOverride !== undefined && typeof policy.allowPerCardRiskOverride !== "boolean") {
|
|
1728
|
+
return { ok: false, reason: "the automation policy allowPerCardRiskOverride must be a boolean (fails closed)" };
|
|
1729
|
+
}
|
|
1730
|
+
// Repository/base policy (item 4): a CLOSED list of accepted repositories.
|
|
1731
|
+
// Dispatch may only run in a repository on this list; arbitrary repository
|
|
1732
|
+
// overrides outside it are rejected.
|
|
1733
|
+
if (!Array.isArray(policy.acceptedRepositories) || policy.acceptedRepositories.length === 0
|
|
1734
|
+
|| !policy.acceptedRepositories.every((repo) => typeof repo === "string" && repo !== "" && !repo.includes(".."))) {
|
|
1735
|
+
return { ok: false, reason: "the automation policy acceptedRepositories must be a non-empty closed list of repository paths (fails closed)" };
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// Pulse (§3): the optional closed pulse object is validated in place. A
|
|
1739
|
+
// policy without `pulse` is unchanged and means Pulse is disabled.
|
|
1740
|
+
if (policy.pulse !== undefined) {
|
|
1741
|
+
const pulse = checkPulsePolicy(policy.pulse, { roles: policy.roles });
|
|
1742
|
+
if (!pulse.ok) return { ok: false, reason: pulse.reason };
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
return { ok: true, policy, reason: null };
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// Item 4: a repository override is honored only when it is on the policy's
|
|
1749
|
+
// closed acceptedRepositories list (exact match).
|
|
1750
|
+
export function repositoryAccepted(policy, repository) {
|
|
1751
|
+
return Array.isArray(policy?.acceptedRepositories) && policy.acceptedRepositories.includes(repository);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// §3.2 assignment envelope: created ONCE per assignment, immutable (deep-
|
|
1755
|
+
// frozen, F2), and single-attempt. Retry, drift, or expiry require a NEW
|
|
1756
|
+
// envelope — never a mutation of this one. F4: the starting revision comes
|
|
1757
|
+
// from the CARD'S repository (resolved from the workspace the board lives
|
|
1758
|
+
// in), the card's base revision is bound in when present (branch chaining),
|
|
1759
|
+
// and the risk classification comes from the policy (per-card override only
|
|
1760
|
+
// when the policy allows).
|
|
1761
|
+
export function createEnvelope({ card, policy, now = null, repository = null, startingRevision = null }) {
|
|
1762
|
+
const at = now ?? new Date().toISOString();
|
|
1763
|
+
const envelopeId = randomBytes(16).toString("hex");
|
|
1764
|
+
const expiryHours = Number.isFinite(policy?.envelopeExpiryHours) && policy.envelopeExpiryHours > 0
|
|
1765
|
+
? policy.envelopeExpiryHours
|
|
1766
|
+
: DEFAULT_ENVELOPE_EXPIRY_HOURS;
|
|
1767
|
+
let expiry = new Date(Date.parse(at) + expiryHours * 3_600_000).toISOString();
|
|
1768
|
+
// The envelope can never outlive the policy that authorized it.
|
|
1769
|
+
if (policy?.expiry && Date.parse(policy.expiry) < Date.parse(expiry)) expiry = policy.expiry;
|
|
1770
|
+
const repo = repository ?? (policy?.board ? dirname(policy.board) : process.cwd());
|
|
1771
|
+
const overrideAllowed = policy?.allowPerCardRiskOverride === true;
|
|
1772
|
+
const cardRisk = typeof card?.risk === "string" && RISK_LEVELS.includes(card.risk) ? card.risk : null;
|
|
1773
|
+
const risk = overrideAllowed && cardRisk !== null ? cardRisk : policy?.riskCeiling ?? "low";
|
|
1774
|
+
// Item 4: work STARTS from the declared base revision where present — the
|
|
1775
|
+
// envelope's starting revision IS the base (branch chaining), not merely a
|
|
1776
|
+
// copied field; execution validation enforces HEAD === startingRevision.
|
|
1777
|
+
const startRev = startingRevision ?? card.base ?? gitHead(repo);
|
|
1778
|
+
return deepFreeze({
|
|
1779
|
+
schema: ENVELOPE_SCHEMA,
|
|
1780
|
+
envelopeId,
|
|
1781
|
+
cardId: card.cardId,
|
|
1782
|
+
cardHash: card.hash ?? null,
|
|
1783
|
+
repository: repo,
|
|
1784
|
+
startingRevision: startRev,
|
|
1785
|
+
baseRevision: card.base ?? null,
|
|
1786
|
+
branch: `board/${card.cardId}-${envelopeId.slice(0, 8)}`,
|
|
1787
|
+
allowedPaths: Object.freeze([...(card.scope ?? [])]),
|
|
1788
|
+
unchangedPaths: Object.freeze([...(card.unchangedPaths ?? [])]),
|
|
1789
|
+
capabilities: Object.freeze([...(card.capabilities ?? [])]),
|
|
1790
|
+
stoppingPoint: card.stoppingPoint ?? null,
|
|
1791
|
+
acceptance: Object.freeze({ specHash: card.specHash ?? null, dodHash: card.dodHash ?? null }),
|
|
1792
|
+
placement: policy?.placement ?? null,
|
|
1793
|
+
interactionProfile: policy?.placement ?? null,
|
|
1794
|
+
risk,
|
|
1795
|
+
riskCeiling: policy?.riskCeiling ?? null,
|
|
1796
|
+
mode: "automated",
|
|
1797
|
+
createdAt: at,
|
|
1798
|
+
expiry,
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function deepFreeze(value) {
|
|
1803
|
+
if (value !== null && typeof value === "object") {
|
|
1804
|
+
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
1805
|
+
Object.freeze(value);
|
|
1806
|
+
}
|
|
1807
|
+
return value;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// Read-only git observation for the envelope's starting revision. Git
|
|
1811
|
+
// OPERATIONS belong to the git extension (§0.9); reading HEAD is not one.
|
|
1812
|
+
export function gitHead(cwd = process.cwd()) {
|
|
1813
|
+
try {
|
|
1814
|
+
return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim() || null;
|
|
1815
|
+
} catch {
|
|
1816
|
+
return null;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
export function gitBranch(cwd = process.cwd()) {
|
|
1821
|
+
try {
|
|
1822
|
+
return execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8" }).trim() || null;
|
|
1823
|
+
} catch {
|
|
1824
|
+
return null;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// §3.3 + §4 dispatch eligibility, evaluated under the writer lock: the card
|
|
1829
|
+
// must be dispatchable per the pure predicate (which already enforces the
|
|
1830
|
+
// blocked-by gate, hash validity, and — with statePath set — writer
|
|
1831
|
+
// provenance), not already claimed, and its cardId must be in the writer's
|
|
1832
|
+
// issued-IDs ledger.
|
|
1833
|
+
export function dispatchEligibility({ card, boardIndex, boardPath, activeClaims }) {
|
|
1834
|
+
const claimed = new Set((activeClaims ?? []).map((claim) => claim.cardId));
|
|
1835
|
+
if (claimed.has(card.cardId)) {
|
|
1836
|
+
return { eligible: false, reason: `card ${card.cardId} is already claimed` };
|
|
1837
|
+
}
|
|
1838
|
+
const withState = { ...card, statePath: writerStatePath(boardPath) };
|
|
1839
|
+
const result = isDispatchable(withState, boardIndex);
|
|
1840
|
+
if (!result.dispatchable) {
|
|
1841
|
+
return { eligible: false, reason: result.failedConditions.join("; ") };
|
|
1842
|
+
}
|
|
1843
|
+
return { eligible: true, reason: null };
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
const PRIORITY_ORDER = Object.freeze({ P0: 0, P1: 1, P2: 2, P3: 3 });
|
|
1847
|
+
|
|
1848
|
+
// Select the highest-priority dispatchable, unclaimed, provenance-verified
|
|
1849
|
+
// card. Optional cardId restricts selection to that card.
|
|
1850
|
+
export function selectDispatchableCard({ cards, boardPath, activeClaims, cardId = null }) {
|
|
1851
|
+
const index = new Map(cards.map((card) => [card.cardId, card]));
|
|
1852
|
+
const candidates = cards
|
|
1853
|
+
.filter((card) => cardId === null || card.cardId === cardId)
|
|
1854
|
+
.map((card) => ({ card, eligibility: dispatchEligibility({ card, boardIndex: index, boardPath, activeClaims }) }))
|
|
1855
|
+
.filter((entry) => entry.eligibility.eligible)
|
|
1856
|
+
.sort((a, b) =>
|
|
1857
|
+
(PRIORITY_ORDER[a.card.priority] ?? 99) - (PRIORITY_ORDER[b.card.priority] ?? 99)
|
|
1858
|
+
|| String(a.card.cardId).localeCompare(String(b.card.cardId)));
|
|
1859
|
+
return candidates[0] ?? null;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// §3.6 + §4: THE atomic claim. Under the writer lock, in one operation:
|
|
1863
|
+
// eligibility check → envelope creation → claims-file persist → projection
|
|
1864
|
+
// republication with the card shown active. A crash leaves either the old or
|
|
1865
|
+
// the new complete state (both writes are atomic renames); two concurrent
|
|
1866
|
+
// claims can never both win because the entire read-decide-write sequence
|
|
1867
|
+
// holds the lock. Expired claims are released first, inside the same lock.
|
|
1868
|
+
export function claimCard({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision }) {
|
|
1869
|
+
return claimCardInternal({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch: null });
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// ---------------------------------------------------------------------------
|
|
1873
|
+
// Trusted Pulse-internal confirmed-batch claim path. Host placement is
|
|
1874
|
+
// claimable ONLY here, and only for a batch context object this module has
|
|
1875
|
+
// registered after one native confirmation (registerConfirmedBatchForClaims),
|
|
1876
|
+
// matched by object identity — never by batchId string, user/model field, or
|
|
1877
|
+
// durable state. Each confirmed entry is single-use: one claim consumes it,
|
|
1878
|
+
// and a claim for a card outside the confirmed set is refused.
|
|
1879
|
+
// ---------------------------------------------------------------------------
|
|
1880
|
+
|
|
1881
|
+
// batch context object (WeakMap key) -> Set of cardIds still claimable from it.
|
|
1882
|
+
const confirmedBatchClaims = new WeakMap();
|
|
1883
|
+
|
|
1884
|
+
export function registerConfirmedBatchForClaims(batch) {
|
|
1885
|
+
if (batch?.schema !== "agentic-driver.pulse-batch.v1" || typeof batch.batchId !== "string" || !Array.isArray(batch.entries)) {
|
|
1886
|
+
throw new Error("registerConfirmedBatchForClaims requires a minted pulse batch context");
|
|
1887
|
+
}
|
|
1888
|
+
confirmedBatchClaims.set(batch, new Set(batch.entries.map((entry) => entry.cardId)));
|
|
1889
|
+
return batch.batchId;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
export function claimCardForConfirmedBatch({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null, confirmedBatch = null }) {
|
|
1893
|
+
if (confirmedBatch === null || !confirmedBatchClaims.has(confirmedBatch)) {
|
|
1894
|
+
return { ok: false, code: "confirmed-batch-required", reason: "host placement is claimable only through the Pulse confirmed-batch path with a batch registered after native confirmation (fails closed)" };
|
|
1895
|
+
}
|
|
1896
|
+
if (typeof cardId !== "string" || cardId === "") {
|
|
1897
|
+
return { ok: false, code: "confirmed-batch-card-required", reason: "a confirmed-batch claim requires an explicit cardId from the confirmed batch (fails closed)" };
|
|
1898
|
+
}
|
|
1899
|
+
const expiresMs = Date.parse(confirmedBatch.expiresAt);
|
|
1900
|
+
const nowMs = Date.parse(now ?? new Date().toISOString());
|
|
1901
|
+
if (!Number.isFinite(expiresMs) || !Number.isFinite(nowMs)) {
|
|
1902
|
+
return { ok: false, code: "confirmed-batch-time-invalid", reason: "the confirmed batch expiry or current time is invalid (fails closed)" };
|
|
1903
|
+
}
|
|
1904
|
+
if (nowMs >= expiresMs) {
|
|
1905
|
+
return { ok: false, code: "confirmed-batch-expired", reason: "the confirmed batch context has expired; a fresh preview and confirmation is required" };
|
|
1906
|
+
}
|
|
1907
|
+
const entry = confirmedBatch.entries.find((e) => e.cardId === cardId);
|
|
1908
|
+
if (!entry || entry.placement !== "host") {
|
|
1909
|
+
return { ok: false, code: "confirmed-batch-card-mismatch", reason: "the requested card is not a host entry in the confirmed batch; confirmations cannot be crossed between batch entries" };
|
|
1910
|
+
}
|
|
1911
|
+
if (entry.state !== "reserved") {
|
|
1912
|
+
return { ok: false, code: "confirmed-batch-entry-not-reserved", reason: `confirmed batch entry is ${entry.state}, not reserved; it cannot be claimed again (single-use)` };
|
|
1913
|
+
}
|
|
1914
|
+
const claimable = confirmedBatchClaims.get(confirmedBatch);
|
|
1915
|
+
if (!claimable.has(cardId)) {
|
|
1916
|
+
return { ok: false, code: "confirmed-batch-entry-consumed", reason: "the confirmed batch entry was already claimed; confirmation is single-use" };
|
|
1917
|
+
}
|
|
1918
|
+
const result = claimCardInternal({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch });
|
|
1919
|
+
if (result.ok) claimable.delete(cardId); // consume the single-use confirmation
|
|
1920
|
+
return result;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function claimCardInternal({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null, confirmedBatch = null }) {
|
|
1924
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
1925
|
+
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
1926
|
+
}
|
|
1927
|
+
if (typeof role !== "string" || role === "") {
|
|
1928
|
+
throw Object.assign(new Error("role is required"), { code: "role-required" });
|
|
1929
|
+
}
|
|
1930
|
+
// Contended locks (two pulses racing across processes) retry briefly and
|
|
1931
|
+
// then fail as a structured contention error — never a crash. The claim
|
|
1932
|
+
// itself stays atomic: whoever takes the lock first wins the card.
|
|
1933
|
+
const { sleepSync } = { sleepSync: (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) };
|
|
1934
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1935
|
+
try {
|
|
1936
|
+
return withWriterLock(boardPath, () =>
|
|
1937
|
+
claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch }));
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
if (error?.code === "writer-lock-held" && attempt < 20) {
|
|
1940
|
+
sleepSync(25);
|
|
1941
|
+
continue;
|
|
1942
|
+
}
|
|
1943
|
+
if (error?.code === "writer-lock-held") {
|
|
1944
|
+
return { ok: false, code: "lock-contention", reason: "the board writer lock stayed contended; the claim did not land (no card was double-claimed)" };
|
|
1945
|
+
}
|
|
1946
|
+
throw error;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch = null }) {
|
|
1952
|
+
const at = now ?? new Date().toISOString();
|
|
1953
|
+
if (!existsSync(boardPath)) {
|
|
1954
|
+
return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
|
|
1955
|
+
}
|
|
1956
|
+
if (!ROLE_NAME_RE.test(role)) {
|
|
1957
|
+
return { ok: false, code: "role-invalid", reason: `role "${role}" does not match ${ROLE_NAME_RE.source} (fails closed)` };
|
|
1958
|
+
}
|
|
1959
|
+
const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
|
|
1960
|
+
if (!anchorRecovery.ok) {
|
|
1961
|
+
return { ok: false, code: "claims-anchor-recovery-failed", recoverable: true, reason: anchorRecovery.reason };
|
|
1962
|
+
}
|
|
1963
|
+
// F1: read AND verify the claims file. Corruption fails closed.
|
|
1964
|
+
const claimsRead = readClaimsState(boardPath);
|
|
1965
|
+
if (!claimsRead.ok) {
|
|
1966
|
+
return { ok: false, code: "claims-corrupt", reason: claimsRead.reason };
|
|
1967
|
+
}
|
|
1968
|
+
// F3: reconcile an interrupted transaction before anything else. A crash
|
|
1969
|
+
// between the claims-file write and the projection republish leaves a
|
|
1970
|
+
// transaction record; roll it forward (republish the projection) so a live
|
|
1971
|
+
// claim never exists without its active-state publication, and an envelope
|
|
1972
|
+
// never exists without its claim (the envelope is written inside the same
|
|
1973
|
+
// claims record, so claims-file presence IS claim+envelope presence).
|
|
1974
|
+
// If reconciliation itself fails, REFUSE the mutation — never clear or
|
|
1975
|
+
// bypass the pending transaction (item 3).
|
|
1976
|
+
const reconciliation = reconcileTransactionLocked({ boardPath, state: claimsRead.state });
|
|
1977
|
+
if (reconciliation !== null && reconciliation.failed) {
|
|
1978
|
+
return { ok: false, code: "recovery-failed", recoverable: true, reason: reconciliation.reason,
|
|
1979
|
+
errors: [reconciliation.reason] };
|
|
1980
|
+
}
|
|
1981
|
+
// Automation policy first (§4): no policy = no overnight dispatch.
|
|
1982
|
+
const resolvedPolicy = policy ?? readAutomationPolicy(boardPath, { configPath });
|
|
1983
|
+
const policyCheck = checkAutomationPolicy(resolvedPolicy, { now: at, boardPath });
|
|
1984
|
+
if (!policyCheck.ok) {
|
|
1985
|
+
return { ok: false, code: "policy-refused", reason: policyCheck.reason };
|
|
1986
|
+
}
|
|
1987
|
+
if (!policyCheck.policy.roles.includes(role)) {
|
|
1988
|
+
return { ok: false, code: "policy-role-refused", reason: `role "${role}" is not declared in the automation policy (fails closed)` };
|
|
1989
|
+
}
|
|
1990
|
+
// §3.4 mode table: ordinary claims are contained-only (container/microVM),
|
|
1991
|
+
// always. Host placement is claimable ONLY through the trusted Pulse-internal
|
|
1992
|
+
// confirmed-batch path (claimCardForConfirmedBatch), which requires a batch
|
|
1993
|
+
// context registered after one native confirmation. Static pulse.mode=
|
|
1994
|
+
// "interactive" plus host placement is never enough on its own; automated
|
|
1995
|
+
// dispatch may never use host placement.
|
|
1996
|
+
if (confirmedBatch === null && policyCheck.policy.placement !== "container") {
|
|
1997
|
+
return { ok: false, code: "policy-placement-refused", reason: "ordinary board dispatch requires container placement per the automation policy (§3.4); host placement is claimable only through the Pulse confirmed-batch path" };
|
|
1998
|
+
}
|
|
1999
|
+
if (confirmedBatch !== null && policyCheck.policy.placement !== "host") {
|
|
2000
|
+
return { ok: false, code: "policy-placement-refused", reason: "the Pulse confirmed-batch claim path requires host placement (fails closed)" };
|
|
2001
|
+
}
|
|
2002
|
+
// F4: the starting revision comes from the card's repository — the
|
|
2003
|
+
// workspace the board lives in, not process.cwd(). A caller-supplied
|
|
2004
|
+
// repository override is honored ONLY when it is on the policy's closed
|
|
2005
|
+
// acceptedRepositories list (item 4).
|
|
2006
|
+
const defaultRepo = dirname(boardPath);
|
|
2007
|
+
const repo = repository !== undefined && repository !== null
|
|
2008
|
+
? (repositoryAccepted(policyCheck.policy, repository) ? repository : null)
|
|
2009
|
+
: defaultRepo;
|
|
2010
|
+
if (repo === null) {
|
|
2011
|
+
return { ok: false, code: "policy-repository-refused", reason: `repository "${repository}" is not on the automation policy's acceptedRepositories list (fails closed)` };
|
|
2012
|
+
}
|
|
2013
|
+
if (!repositoryAccepted(policyCheck.policy, repo)) {
|
|
2014
|
+
return { ok: false, code: "policy-repository-refused", reason: `repository "${repo}" is not on the automation policy's acceptedRepositories list (fails closed)` };
|
|
2015
|
+
}
|
|
2016
|
+
// Expired envelopes release their claims automatically on check (§4.6).
|
|
2017
|
+
const activeClaims = releaseExpiredClaimsLocked({ boardPath, at, state: claimsRead.state });
|
|
2018
|
+
const concurrency = Number(policyCheck.policy.maxConcurrent);
|
|
2019
|
+
if (activeClaims.length >= concurrency) {
|
|
2020
|
+
return { ok: false, code: "policy-concurrency-refused", reason: `the automation policy allows at most ${concurrency} concurrent claim(s); ${activeClaims.length} are active` };
|
|
2021
|
+
}
|
|
2022
|
+
const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2023
|
+
if (!validatedBoard.ok) {
|
|
2024
|
+
return { ok: false, code: "board-invalid", errors: validatedBoard.errors };
|
|
2025
|
+
}
|
|
2026
|
+
// F2: an envelope whose attempt was consumed (expired, reclaimed, or
|
|
2027
|
+
// completed) can never be reused — the consumed-attempt marker is
|
|
2028
|
+
// HMAC-covered in the claims file, and readClaimsState refuses any active
|
|
2029
|
+
// claim referencing a consumed envelopeId. A retry mints a NEW envelope.
|
|
2030
|
+
const consumedIds = new Set(claimsRead.state.consumedClaims.map((entry) => entry.envelopeId));
|
|
2031
|
+
if (cardId !== null && claimsRead.state.claims.some((claim) => claim.cardId === cardId && consumedIds.has(claim.envelopeId))) {
|
|
2032
|
+
return { ok: false, code: "envelope-consumed", reason: `card ${cardId} has a consumed envelope attempt; a retry requires a new dispatch and a new envelope` };
|
|
2033
|
+
}
|
|
2034
|
+
const selected = selectDispatchableCard({ cards: validatedBoard.cards, boardPath, activeClaims, cardId });
|
|
2035
|
+
if (selected === null) {
|
|
2036
|
+
return { ok: false, code: "no-dispatchable-card", reason: cardId
|
|
2037
|
+
? `card ${cardId} is not dispatchable, is already claimed, or lacks writer provenance`
|
|
2038
|
+
: "no dispatchable unclaimed card is available" };
|
|
2039
|
+
}
|
|
2040
|
+
const card = selected.card;
|
|
2041
|
+
const envelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: repo, startingRevision });
|
|
2042
|
+
const claim = {
|
|
2043
|
+
cardId: card.cardId,
|
|
2044
|
+
claimedAt: at,
|
|
2045
|
+
role,
|
|
2046
|
+
envelopeId: envelope.envelopeId,
|
|
2047
|
+
envelope,
|
|
2048
|
+
};
|
|
2049
|
+
const nextClaims = [...activeClaims, claim];
|
|
2050
|
+
// F3: the transaction record goes into the claims file BEFORE the
|
|
2051
|
+
// projection rename. Both writes are atomic renames under the lock, so a
|
|
2052
|
+
// crash leaves either the old or the new complete state, and the record
|
|
2053
|
+
// drives roll-forward recovery on the next operation.
|
|
2054
|
+
const transaction = { op: "claim", cardId: card.cardId, envelopeId: envelope.envelopeId, at, phase: "claims-written" };
|
|
2055
|
+
writeClaims(boardPath, nextClaims, { transaction });
|
|
2056
|
+
// Active-state publication: the projection is recomputed with the claim
|
|
2057
|
+
// visible, in the same locked operation (§3.6).
|
|
2058
|
+
const projection = writeProjection(boardPath, validatedBoard.cards, nextClaims);
|
|
2059
|
+
// Item 3: a projection failure returns ok:false as a STRUCTURED
|
|
2060
|
+
// RECOVERABLE failure — the claim and envelope are committed in the
|
|
2061
|
+
// authenticated claims file with the transaction retained; the next
|
|
2062
|
+
// operation rolls the projection forward. Never silently ok:true.
|
|
2063
|
+
if (!projection.written) {
|
|
2064
|
+
return {
|
|
2065
|
+
ok: false,
|
|
2066
|
+
claimed: true,
|
|
2067
|
+
code: "claim-recoverable",
|
|
2068
|
+
recoverable: true,
|
|
2069
|
+
reason: `projection publication failed: ${projection.error ?? "unknown"} (claim committed; transaction retained for roll-forward)`,
|
|
2070
|
+
cardId: card.cardId,
|
|
2071
|
+
role,
|
|
2072
|
+
claim,
|
|
2073
|
+
envelope,
|
|
2074
|
+
projection,
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
// F3: finalize — clear the transaction record now that the projection is
|
|
2078
|
+
// published.
|
|
2079
|
+
finalizeTransactionLocked({ boardPath, claims: nextClaims, projection });
|
|
2080
|
+
return {
|
|
2081
|
+
ok: true,
|
|
2082
|
+
claimed: true,
|
|
2083
|
+
cardId: card.cardId,
|
|
2084
|
+
role,
|
|
2085
|
+
claim,
|
|
2086
|
+
envelope,
|
|
2087
|
+
projection,
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// F3: roll an interrupted claim transaction forward — the claims (and their
|
|
2092
|
+
// envelopes) are already committed in the authenticated claims file, so only
|
|
2093
|
+
// the projection publication can be stale; republish it. Returns
|
|
2094
|
+
// {failed: true, reason} when roll-forward fails; callers must REFUSE the
|
|
2095
|
+
// mutation and never clear the pending transaction (item 3).
|
|
2096
|
+
function reconcileTransactionLocked({ boardPath, state }) {
|
|
2097
|
+
const transaction = state.transaction;
|
|
2098
|
+
if (transaction === null || transaction.phase !== "claims-written") return null;
|
|
2099
|
+
if (!existsSync(boardPath)) {
|
|
2100
|
+
return { failed: true, reason: "a pending claim transaction exists but the board file is unavailable — recovery failed (fails closed)" };
|
|
2101
|
+
}
|
|
2102
|
+
const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2103
|
+
if (!validated.ok) {
|
|
2104
|
+
return { failed: true, reason: "a pending claim transaction exists but the board is invalid — recovery failed (fails closed)" };
|
|
2105
|
+
}
|
|
2106
|
+
const projection = writeProjection(boardPath, validated.cards, state.claims);
|
|
2107
|
+
if (!projection.written) {
|
|
2108
|
+
return { failed: true, reason: `projection republication failed during recovery: ${projection.error ?? "unknown"} (transaction retained)` };
|
|
2109
|
+
}
|
|
2110
|
+
writeClaims(boardPath, state.claims, { transaction: null });
|
|
2111
|
+
return { failed: false };
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// F3: clear the transaction record once the projection is published.
|
|
2115
|
+
function finalizeTransactionLocked({ boardPath, claims, projection }) {
|
|
2116
|
+
if (!projection?.written) {
|
|
2117
|
+
// Leave the transaction record in place so the next operation reconciles.
|
|
2118
|
+
return `projection publication failed: ${projection?.error ?? "unknown"} (transaction record retained for recovery)`;
|
|
2119
|
+
}
|
|
2120
|
+
writeClaims(boardPath, claims, { transaction: null });
|
|
2121
|
+
return null;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
// F2/Item 2: whether an envelope attempt has been consumed (expired,
|
|
2125
|
+
// reclaimed, or completed) — the journey layer checks this before executing
|
|
2126
|
+
// an envelope.
|
|
2127
|
+
export function isEnvelopeConsumed(boardPath, envelopeId) {
|
|
2128
|
+
const read = readClaimsState(boardPath);
|
|
2129
|
+
if (!read.ok) throw Object.assign(new Error(read.reason), { code: "claims-corrupt" });
|
|
2130
|
+
return read.state.consumedClaims.some((entry) => entry.envelopeId === envelopeId);
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// Prepare the one assigned branch before execution. Authentication happens
|
|
2134
|
+
// first against the persisted claim; only a missing assigned branch may be
|
|
2135
|
+
// created, and only from the envelope's exact starting revision. Existing
|
|
2136
|
+
// branch mismatch is drift, not an implicit checkout.
|
|
2137
|
+
export function prepareEnvelopeForExecution({ boardPath, envelope }) {
|
|
2138
|
+
if (typeof boardPath !== "string" || boardPath === "" || !wellFormedEnvelope(envelope)) {
|
|
2139
|
+
return { ok: false, code: "envelope-invalid", reason: "a valid board path and envelope are required" };
|
|
2140
|
+
}
|
|
2141
|
+
try {
|
|
2142
|
+
return withWriterLock(boardPath, () => {
|
|
2143
|
+
const recovery = recoverClaimsAnchorLocked(boardPath);
|
|
2144
|
+
if (!recovery.ok) return { ok: false, code: "claims-anchor-recovery-failed", reason: recovery.reason };
|
|
2145
|
+
const guard = validateEnvelopeForExecutionLocked({ boardPath, envelope, requireBranch: false });
|
|
2146
|
+
if (!guard.ok) return guard;
|
|
2147
|
+
const current = gitBranch(envelope.repository);
|
|
2148
|
+
if (current === envelope.branch) return { ok: true, prepared: false, envelope: guard.envelope };
|
|
2149
|
+
if (gitHead(envelope.repository) !== envelope.startingRevision) {
|
|
2150
|
+
return { ok: false, code: "revision-drift", reason: "the repository is not at the envelope starting revision (fails closed)" };
|
|
2151
|
+
}
|
|
2152
|
+
try {
|
|
2153
|
+
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${envelope.branch}`], { cwd: envelope.repository });
|
|
2154
|
+
return { ok: false, code: "branch-drift", reason: `assigned branch ${envelope.branch} already exists but is not checked out (fails closed)` };
|
|
2155
|
+
} catch (error) {
|
|
2156
|
+
if (error?.status !== 1) return { ok: false, code: "branch-unreadable", reason: "the assigned branch state could not be verified (fails closed)" };
|
|
2157
|
+
}
|
|
2158
|
+
execFileSync("git", ["switch", "-c", envelope.branch, envelope.startingRevision], { cwd: envelope.repository, stdio: "ignore" });
|
|
2159
|
+
return { ok: true, prepared: true, envelope: guard.envelope };
|
|
2160
|
+
});
|
|
2161
|
+
} catch (error) {
|
|
2162
|
+
return { ok: false, code: error?.code || "branch-prepare-failed", reason: String(error?.message || error).slice(0, 512) };
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// Item 2: the authoritative execution-boundary validation. Before an
|
|
2167
|
+
// envelope is executed or resumed, the journey layer MUST call this: it
|
|
2168
|
+
// validates authenticated consumption, expiry, card-hash drift, repository
|
|
2169
|
+
// drift, and HEAD/base/branch drift against the CURRENT board and repository.
|
|
2170
|
+
// Any drift fails closed — retry requires a new envelope.
|
|
2171
|
+
export function validateEnvelopeForExecution({ boardPath, envelope, now = null }) {
|
|
2172
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
2173
|
+
return { ok: false, code: "board-path-required", reason: "boardPath is required" };
|
|
2174
|
+
}
|
|
2175
|
+
try {
|
|
2176
|
+
return withWriterLock(boardPath, () => validateEnvelopeForExecutionLocked({ boardPath, envelope, now }));
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
return { ok: false, code: error?.code || "envelope-validation-failed", reason: String(error?.message || error).slice(0, 512) };
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function validateEnvelopeForExecutionLocked({ boardPath, envelope, now = null, requireBranch = true }) {
|
|
2183
|
+
const at = now ?? new Date().toISOString();
|
|
2184
|
+
if (!wellFormedEnvelope(envelope)) {
|
|
2185
|
+
return { ok: false, code: "envelope-invalid", reason: "the envelope is malformed or has an unknown shape (fails closed)" };
|
|
2186
|
+
}
|
|
2187
|
+
const recovery = recoverClaimsAnchorLocked(boardPath);
|
|
2188
|
+
if (!recovery.ok) return { ok: false, code: "claims-anchor-recovery-failed", reason: recovery.reason };
|
|
2189
|
+
const read = readClaimsState(boardPath);
|
|
2190
|
+
if (!read.ok) return { ok: false, code: "claims-corrupt", reason: read.reason };
|
|
2191
|
+
const claim = read.state.claims.find((entry) => entry.envelopeId === envelope.envelopeId);
|
|
2192
|
+
if (!claim) {
|
|
2193
|
+
const consumed = read.state.consumedClaims.some((entry) => entry.envelopeId === envelope.envelopeId);
|
|
2194
|
+
return { ok: false, code: "envelope-not-active",
|
|
2195
|
+
reason: consumed
|
|
2196
|
+
? "the envelope attempt was consumed — a retry requires a new envelope (single-attempt lifecycle)"
|
|
2197
|
+
: "the envelope has no active claim on the board (fails closed)" };
|
|
2198
|
+
}
|
|
2199
|
+
// The caller does not get to supply a well-formed variant. Execution uses
|
|
2200
|
+
// exactly the envelope authenticated inside the active claims state.
|
|
2201
|
+
if (canonicalJsonString(envelope) !== canonicalJsonString(claim.envelope)) {
|
|
2202
|
+
return { ok: false, code: "envelope-authentication-failed", reason: "the supplied envelope does not exactly match the authenticated active-claim envelope (fails closed)" };
|
|
2203
|
+
}
|
|
2204
|
+
// Single-attempt lifecycle: expiry.
|
|
2205
|
+
if (Date.parse(envelope.expiry) <= Date.parse(at)) {
|
|
2206
|
+
return { ok: false, code: "envelope-expired", reason: `the envelope expired at ${envelope.expiry} (single-attempt lifecycle)` };
|
|
2207
|
+
}
|
|
2208
|
+
// Card hash drift: the board's current card must still hash to the
|
|
2209
|
+
// envelope's binding.
|
|
2210
|
+
if (!existsSync(boardPath)) {
|
|
2211
|
+
return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
|
|
2212
|
+
}
|
|
2213
|
+
const board = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2214
|
+
if (!board.ok) return { ok: false, code: "board-invalid", reason: "the board is invalid (fails closed)" };
|
|
2215
|
+
const card = board.cards.find((entry) => entry.cardId === envelope.cardId);
|
|
2216
|
+
if (!card) {
|
|
2217
|
+
return { ok: false, code: "card-not-found", reason: `card ${envelope.cardId} no longer exists on the board (drift — new envelope required)` };
|
|
2218
|
+
}
|
|
2219
|
+
if ((card.hash ?? computeCardHash(card)) !== envelope.cardHash) {
|
|
2220
|
+
return { ok: false, code: "card-hash-drift", reason: "the card hash drifted from the envelope binding — semantic edits require a new envelope (fails closed)" };
|
|
2221
|
+
}
|
|
2222
|
+
// Repository drift.
|
|
2223
|
+
if (envelope.repository !== dirname(boardPath)) {
|
|
2224
|
+
return { ok: false, code: "repository-drift", reason: `the envelope is bound to repository "${envelope.repository}", not this board's repository (fails closed)` };
|
|
2225
|
+
}
|
|
2226
|
+
// HEAD/base/branch drift: work must start from the declared starting
|
|
2227
|
+
// revision (the card's base revision where present, per item 4) — the
|
|
2228
|
+
// repository's current HEAD must equal it.
|
|
2229
|
+
if (envelope.startingRevision !== null) {
|
|
2230
|
+
const head = gitHead(envelope.repository);
|
|
2231
|
+
if (head === null) {
|
|
2232
|
+
return { ok: false, code: "head-unreadable", reason: "the envelope's repository HEAD could not be read (fails closed)" };
|
|
2233
|
+
}
|
|
2234
|
+
if (head !== envelope.startingRevision) {
|
|
2235
|
+
return { ok: false, code: "revision-drift",
|
|
2236
|
+
reason: `repository HEAD ${head} does not match the envelope's starting revision ${envelope.startingRevision} (drift — new envelope required)` };
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
const branch = gitBranch(envelope.repository);
|
|
2240
|
+
if (requireBranch && branch !== envelope.branch) {
|
|
2241
|
+
return { ok: false, code: "branch-drift",
|
|
2242
|
+
reason: `repository branch ${branch ?? "(detached)"} does not match the assigned branch ${envelope.branch} (fails closed)` };
|
|
2243
|
+
}
|
|
2244
|
+
return { ok: true, claim, envelope: claim.envelope, reason: null };
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// Item 2: completion consumption — when the work reaches its stopping point,
|
|
2248
|
+
// the envelope attempt is consumed ("completed") and its claim released.
|
|
2249
|
+
// Never marks the card done (completion is human-only, §3.1).
|
|
2250
|
+
export function consumeEnvelope({ boardPath, envelopeId, reason = "completed" } = {}) {
|
|
2251
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
2252
|
+
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
2253
|
+
}
|
|
2254
|
+
if (!CONSUMED_REASONS.includes(reason)) {
|
|
2255
|
+
throw Object.assign(new Error(`reason must be one of ${CONSUMED_REASONS.join(", ")}`), { code: "invalid-input" });
|
|
2256
|
+
}
|
|
2257
|
+
return withWriterLock(boardPath, () => {
|
|
2258
|
+
const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
|
|
2259
|
+
if (!anchorRecovery.ok) return { ok: false, code: "claims-anchor-recovery-failed", recoverable: true, reason: anchorRecovery.reason };
|
|
2260
|
+
const read = readClaimsState(boardPath);
|
|
2261
|
+
if (!read.ok) return { ok: false, code: "claims-corrupt", reason: read.reason };
|
|
2262
|
+
const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
|
|
2263
|
+
if (reconciliation?.failed) {
|
|
2264
|
+
return { ok: false, code: "recovery-failed", recoverable: true, reason: reconciliation.reason };
|
|
2265
|
+
}
|
|
2266
|
+
const claim = read.state.claims.find((entry) => entry.envelopeId === envelopeId);
|
|
2267
|
+
if (!claim) {
|
|
2268
|
+
return { ok: false, code: "envelope-not-active", reason: "the envelope has no active claim to consume" };
|
|
2269
|
+
}
|
|
2270
|
+
const kept = read.state.claims.filter((entry) => entry.envelopeId !== envelopeId);
|
|
2271
|
+
const consumedClaims = [...read.state.consumedClaims, {
|
|
2272
|
+
cardId: claim.cardId,
|
|
2273
|
+
envelopeId,
|
|
2274
|
+
consumedAt: new Date().toISOString(),
|
|
2275
|
+
reason,
|
|
2276
|
+
}];
|
|
2277
|
+
writeClaims(boardPath, kept, { consumedClaims });
|
|
2278
|
+
if (existsSync(boardPath)) {
|
|
2279
|
+
const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2280
|
+
if (validated.ok) writeProjection(boardPath, validated.cards, kept);
|
|
2281
|
+
}
|
|
2282
|
+
return { ok: true, consumed: true, envelopeId, reason, claims: kept };
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
// Release every claim whose envelope expiry has passed. Returns the surviving
|
|
2287
|
+
// active claims and republishes the projection. Safe to call anytime.
|
|
2288
|
+
export function releaseExpiredClaims({ boardPath, now = null } = {}) {
|
|
2289
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
2290
|
+
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
2291
|
+
}
|
|
2292
|
+
return withWriterLock(boardPath, () => {
|
|
2293
|
+
const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
|
|
2294
|
+
if (!anchorRecovery.ok) throw Object.assign(new Error(anchorRecovery.reason), { code: "claims-anchor-recovery-failed" });
|
|
2295
|
+
const read = readClaimsState(boardPath);
|
|
2296
|
+
if (!read.ok) throw Object.assign(new Error(read.reason), { code: "claims-corrupt" });
|
|
2297
|
+
// Item 3: every claims mutation reconciles first; refuse on failure.
|
|
2298
|
+
const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
|
|
2299
|
+
if (reconciliation?.failed) {
|
|
2300
|
+
throw Object.assign(new Error(reconciliation.reason), { code: "recovery-failed" });
|
|
2301
|
+
}
|
|
2302
|
+
return { released: releaseExpiredClaimsLocked({ boardPath, at: now ?? new Date().toISOString(), state: read.state }) };
|
|
2303
|
+
}).released;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function releaseExpiredClaimsLocked({ boardPath, at, state = null }) {
|
|
2307
|
+
const existing = (state ?? readClaimsState(boardPath).state ?? { claims: [] }).claims;
|
|
2308
|
+
const active = existing.filter((claim) => {
|
|
2309
|
+
const expiry = claim?.envelope?.expiry;
|
|
2310
|
+
return typeof expiry === "string" && Date.parse(expiry) > Date.parse(at);
|
|
2311
|
+
});
|
|
2312
|
+
if (active.length !== existing.length) {
|
|
2313
|
+
// F2: a released envelope's attempt is consumed — it can never be
|
|
2314
|
+
// reused; a retry mints a new envelope.
|
|
2315
|
+
const consumedClaims = [...(state?.consumedClaims ?? readClaimsState(boardPath).state?.consumedClaims ?? []),
|
|
2316
|
+
...existing.filter((claim) => !active.includes(claim)).map((claim) => ({
|
|
2317
|
+
cardId: claim.cardId,
|
|
2318
|
+
envelopeId: claim.envelopeId,
|
|
2319
|
+
consumedAt: at,
|
|
2320
|
+
reason: "expired",
|
|
2321
|
+
}))];
|
|
2322
|
+
writeClaims(boardPath, active, { consumedClaims });
|
|
2323
|
+
if (existsSync(boardPath)) {
|
|
2324
|
+
const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2325
|
+
if (validated.ok) writeProjection(boardPath, validated.cards, active);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
return active;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Explicit reclaim: drop the claim for one card (or all claims with cardId
|
|
2332
|
+
// null). Releases the active-state publication. Never marks anything done.
|
|
2333
|
+
export function reclaimClaim({ boardPath, cardId = null, envelopeId = null } = {}) {
|
|
2334
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
2335
|
+
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
2336
|
+
}
|
|
2337
|
+
return withWriterLock(boardPath, () => {
|
|
2338
|
+
const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
|
|
2339
|
+
if (!anchorRecovery.ok) throw Object.assign(new Error(anchorRecovery.reason), { code: "claims-anchor-recovery-failed" });
|
|
2340
|
+
const read = readClaimsState(boardPath);
|
|
2341
|
+
if (!read.ok) throw Object.assign(new Error(read.reason), { code: "claims-corrupt" });
|
|
2342
|
+
// Item 3: reconcile first; refuse the mutation when recovery fails.
|
|
2343
|
+
const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
|
|
2344
|
+
if (reconciliation?.failed) {
|
|
2345
|
+
throw Object.assign(new Error(reconciliation.reason), { code: "recovery-failed" });
|
|
2346
|
+
}
|
|
2347
|
+
const existing = read.state.claims;
|
|
2348
|
+
const kept = existing.filter((claim) =>
|
|
2349
|
+
(cardId !== null ? claim.cardId !== cardId : true)
|
|
2350
|
+
&& (envelopeId !== null ? claim.envelopeId !== envelopeId : true));
|
|
2351
|
+
if (kept.length === existing.length) {
|
|
2352
|
+
return { ok: false, code: "claim-not-found", reason: "no matching claim to reclaim" };
|
|
2353
|
+
}
|
|
2354
|
+
// F2: a reclaimed envelope's attempt is consumed too.
|
|
2355
|
+
const consumedClaims = [...read.state.consumedClaims,
|
|
2356
|
+
...existing.filter((claim) => !kept.includes(claim)).map((claim) => ({
|
|
2357
|
+
cardId: claim.cardId,
|
|
2358
|
+
envelopeId: claim.envelopeId,
|
|
2359
|
+
consumedAt: new Date().toISOString(),
|
|
2360
|
+
reason: "reclaimed",
|
|
2361
|
+
}))];
|
|
2362
|
+
writeClaims(boardPath, kept, { consumedClaims });
|
|
2363
|
+
if (existsSync(boardPath)) {
|
|
2364
|
+
const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2365
|
+
if (validated.ok) writeProjection(boardPath, validated.cards, kept);
|
|
2366
|
+
}
|
|
2367
|
+
return { ok: true, reclaimed: existing.length - kept.length, claims: kept };
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
// §10.4 dispatcher-owned recoverable replaceAttempt: one transition under the
|
|
2372
|
+
// writer lock that replaces a failed/unresponsive attempt with a new attempt
|
|
2373
|
+
// (new envelope, new claim) for the same card. Trusted input: the expected
|
|
2374
|
+
// active card ID, old envelope ID, terminal reason, and replacement route
|
|
2375
|
+
// (role/repository/starting revision). Not a public model action.
|
|
2376
|
+
//
|
|
2377
|
+
// Transaction shape: a prepared transaction is durably staged (HMAC-covered
|
|
2378
|
+
// via the claims file's own authentication) containing the old terminal
|
|
2379
|
+
// evidence and the pre-recorded new attempt identity. Only then is the old
|
|
2380
|
+
// envelope consumed and the new claim published; the transaction record
|
|
2381
|
+
// clears once the projection is republished. Recovery is idempotent and runs
|
|
2382
|
+
// first: if only the old consumption landed, the replacement rolls forward
|
|
2383
|
+
// only while all bound identities still match; otherwise it returns
|
|
2384
|
+
// REVIEW_REQUIRED. Recovery never creates another envelope.
|
|
2385
|
+
const REPLACE_TRANSACTION_OPS = Object.freeze(["replace-attempt"]);
|
|
2386
|
+
const REPLACE_TRANSACTION_PHASES = Object.freeze(["prepared", "committed"]);
|
|
2387
|
+
|
|
2388
|
+
export function replaceAttempt({ boardPath, cardId, envelopeId, reason, replacement = {}, now = null } = {}) {
|
|
2389
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
2390
|
+
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
2391
|
+
}
|
|
2392
|
+
if (typeof cardId !== "string" || !CARD_ID_RE.test(cardId)) {
|
|
2393
|
+
throw Object.assign(new Error("cardId must be a well-formed card ID"), { code: "invalid-input" });
|
|
2394
|
+
}
|
|
2395
|
+
if (typeof envelopeId !== "string" || !/^[0-9a-f]{32}$/.test(envelopeId)) {
|
|
2396
|
+
throw Object.assign(new Error("envelopeId must be a 32-hex envelope ID"), { code: "invalid-input" });
|
|
2397
|
+
}
|
|
2398
|
+
if (!CONSUMED_REASONS.includes(reason)) {
|
|
2399
|
+
throw Object.assign(new Error(`reason must be one of ${CONSUMED_REASONS.join(", ")}`), { code: "invalid-input" });
|
|
2400
|
+
}
|
|
2401
|
+
const at = now ?? new Date().toISOString();
|
|
2402
|
+
return withWriterLock(boardPath, () => replaceAttemptLocked({ boardPath, cardId, envelopeId, reason, replacement, at }));
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function replaceAttemptLocked({ boardPath, cardId, envelopeId, reason, replacement, at }) {
|
|
2406
|
+
// Recovery first (idempotent): roll any interrupted prior transaction
|
|
2407
|
+
// forward before observing state, never creating another envelope.
|
|
2408
|
+
const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
|
|
2409
|
+
if (!anchorRecovery.ok) {
|
|
2410
|
+
return { ok: false, code: "claims-anchor-recovery-failed", recoverable: true, reason: anchorRecovery.reason };
|
|
2411
|
+
}
|
|
2412
|
+
const read = readClaimsState(boardPath);
|
|
2413
|
+
if (!read.ok) return { ok: false, code: "claims-corrupt", reason: read.reason };
|
|
2414
|
+
const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
|
|
2415
|
+
if (reconciliation?.failed) {
|
|
2416
|
+
return { ok: false, code: "recovery-failed", recoverable: true, reason: reconciliation.reason };
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// The old attempt must be the exact active authenticated claim for this
|
|
2420
|
+
// card. Drift, reuse, or a consumed old envelope fails closed.
|
|
2421
|
+
const oldClaim = read.state.claims.find((claim) => claim.envelopeId === envelopeId);
|
|
2422
|
+
if (!oldClaim) {
|
|
2423
|
+
return { ok: false, code: "envelope-not-active",
|
|
2424
|
+
reason: read.state.consumedClaims.some((entry) => entry.envelopeId === envelopeId)
|
|
2425
|
+
? "the old envelope attempt was already consumed — a replacement requires a fresh dispatch (single-attempt lifecycle)"
|
|
2426
|
+
: `no active claim for envelope ${envelopeId} (fails closed)` };
|
|
2427
|
+
}
|
|
2428
|
+
if (oldClaim.cardId !== cardId) {
|
|
2429
|
+
return { ok: false, code: "card-drift", reason: `envelope ${envelopeId} is bound to card ${oldClaim.cardId}, not ${cardId} (fails closed)` };
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// Re-evaluate current observations under the lock: card identity/hash,
|
|
2433
|
+
// policy validity, and capacity for the replacement route.
|
|
2434
|
+
if (!existsSync(boardPath)) {
|
|
2435
|
+
return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
|
|
2436
|
+
}
|
|
2437
|
+
const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2438
|
+
if (!validatedBoard.ok) return { ok: false, code: "board-invalid", errors: validatedBoard.errors };
|
|
2439
|
+
const card = validatedBoard.cards.find((entry) => entry.cardId === cardId);
|
|
2440
|
+
if (!card) return { ok: false, code: "card-not-found", reason: `card ${cardId} no longer exists on the board (drift — REVIEW_REQUIRED)` };
|
|
2441
|
+
const currentHash = card.hash ?? computeCardHash(card);
|
|
2442
|
+
if (currentHash !== oldClaim.envelope.cardHash) {
|
|
2443
|
+
return { ok: false, code: "card-hash-drift", reason: "the card hash drifted from the old envelope binding — semantic edits require human review (REVIEW_REQUIRED)" };
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
const resolvedPolicy = readAutomationPolicy(boardPath);
|
|
2447
|
+
const policyCheck = checkAutomationPolicy(resolvedPolicy, { now: at, boardPath });
|
|
2448
|
+
if (!policyCheck.ok) {
|
|
2449
|
+
return { ok: false, code: "policy-refused", reason: policyCheck.reason };
|
|
2450
|
+
}
|
|
2451
|
+
const replacementRole = typeof replacement.role === "string" && replacement.role !== "" ? replacement.role : oldClaim.role;
|
|
2452
|
+
if (!policyCheck.policy.roles.includes(replacementRole)) {
|
|
2453
|
+
return { ok: false, code: "policy-role-refused", reason: `replacement role "${replacementRole}" is not declared in the automation policy (fails closed)` };
|
|
2454
|
+
}
|
|
2455
|
+
if (policyCheck.policy.placement !== "container") {
|
|
2456
|
+
return { ok: false, code: "policy-placement-refused", reason: "automated board dispatch requires container placement per the automation policy (§3.4)" };
|
|
2457
|
+
}
|
|
2458
|
+
const replacementRepository = typeof replacement.repository === "string" && replacement.repository !== ""
|
|
2459
|
+
? replacement.repository
|
|
2460
|
+
: dirname(boardPath);
|
|
2461
|
+
if (!repositoryAccepted(policyCheck.policy, replacementRepository)) {
|
|
2462
|
+
return { ok: false, code: "policy-repository-refused", reason: `repository "${replacementRepository}" is not on the automation policy's acceptedRepositories list (fails closed)` };
|
|
2463
|
+
}
|
|
2464
|
+
const activeClaims = releaseExpiredClaimsLocked({ boardPath, at, state: read.state });
|
|
2465
|
+
const concurrency = Number(policyCheck.policy.maxConcurrent);
|
|
2466
|
+
// Capacity: the old claim is being replaced, so it frees exactly one slot.
|
|
2467
|
+
if (activeClaims.filter((claim) => claim.envelopeId !== envelopeId).length >= concurrency) {
|
|
2468
|
+
return { ok: false, code: "policy-concurrency-refused", reason: `the automation policy allows at most ${concurrency} concurrent claim(s); no free slot for the replacement` };
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// Pre-record the replacement identity, then durably stage the prepared
|
|
2472
|
+
// transaction BEFORE consuming the old envelope. The staged claims state
|
|
2473
|
+
// carries the complete signed next value, so either crash window rolls
|
|
2474
|
+
// forward deterministically (the same prepare→claims→commit scheme as
|
|
2475
|
+
// claimCard, extended with the replace op and phase).
|
|
2476
|
+
const newEnvelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: replacementRepository, startingRevision: replacement.startingRevision ?? undefined });
|
|
2477
|
+
const newClaim = {
|
|
2478
|
+
cardId,
|
|
2479
|
+
claimedAt: at,
|
|
2480
|
+
role: replacementRole,
|
|
2481
|
+
envelopeId: newEnvelope.envelopeId,
|
|
2482
|
+
envelope: newEnvelope,
|
|
2483
|
+
};
|
|
2484
|
+
const keptClaims = activeClaims.filter((claim) => claim.envelopeId !== envelopeId);
|
|
2485
|
+
const nextClaims = [...keptClaims, newClaim];
|
|
2486
|
+
const consumedClaims = [...read.state.consumedClaims, {
|
|
2487
|
+
cardId, envelopeId, consumedAt: at, reason,
|
|
2488
|
+
}];
|
|
2489
|
+
const transaction = {
|
|
2490
|
+
op: "replace-attempt",
|
|
2491
|
+
cardId,
|
|
2492
|
+
envelopeId: newEnvelope.envelopeId,
|
|
2493
|
+
at,
|
|
2494
|
+
phase: "claims-written",
|
|
2495
|
+
oldEnvelopeId: envelopeId,
|
|
2496
|
+
oldReason: reason,
|
|
2497
|
+
};
|
|
2498
|
+
writeClaims(boardPath, nextClaims, { consumedClaims, transaction });
|
|
2499
|
+
const projection = writeProjection(boardPath, validatedBoard.cards, nextClaims);
|
|
2500
|
+
if (!projection.written) {
|
|
2501
|
+
return {
|
|
2502
|
+
ok: false,
|
|
2503
|
+
replaced: true,
|
|
2504
|
+
code: "replace-recoverable",
|
|
2505
|
+
recoverable: true,
|
|
2506
|
+
reason: `projection publication failed: ${projection.error ?? "unknown"} (replacement committed; transaction retained for roll-forward)`,
|
|
2507
|
+
cardId,
|
|
2508
|
+
oldEnvelopeId: envelopeId,
|
|
2509
|
+
claim: newClaim,
|
|
2510
|
+
envelope: newEnvelope,
|
|
2511
|
+
projection,
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
finalizeTransactionLocked({ boardPath, claims: nextClaims, projection });
|
|
2515
|
+
return {
|
|
2516
|
+
ok: true,
|
|
2517
|
+
replaced: true,
|
|
2518
|
+
cardId,
|
|
2519
|
+
oldEnvelopeId: envelopeId,
|
|
2520
|
+
oldReason: reason,
|
|
2521
|
+
claim: newClaim,
|
|
2522
|
+
envelope: newEnvelope,
|
|
2523
|
+
projection,
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
|
|
1257
2527
|
// ---------------------------------------------------------------------------
|
|
1258
2528
|
// Provider observation (§5 reversibility): everything registers behind the
|
|
1259
2529
|
// observation that a board file exists. No board file, no behavior change and
|
|
@@ -1595,5 +2865,63 @@ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPat
|
|
|
1595
2865
|
});
|
|
1596
2866
|
registered.push("agentic_kanban_board_update");
|
|
1597
2867
|
}
|
|
2868
|
+
// The dispatch tool (§4): claims the highest-priority dispatchable,
|
|
2869
|
+
// unclaimed, provenance-verified card for a role under the user's
|
|
2870
|
+
// automation policy, creates the assignment envelope, and returns the
|
|
2871
|
+
// binding. The policy is the human decision; no policy = refusal. This
|
|
2872
|
+
// tool wires claim + envelope only — journey execution integration is a
|
|
2873
|
+
// follow-up.
|
|
2874
|
+
if (typeof pi?.registerTool === "function") {
|
|
2875
|
+
pi.registerTool({
|
|
2876
|
+
name: "agentic_kanban_board_dispatch",
|
|
2877
|
+
label: "Kanban Board Dispatch",
|
|
2878
|
+
description:
|
|
2879
|
+
"Claim the highest-priority dispatchable, unclaimed task-board card for a role and create its assignment envelope. Governed by the user's automation policy (roles, placement, maxConcurrent, expiry): no policy = refused; a role not in the policy = refused. Atomic: two concurrent claims can never claim the same card. Expired envelopes release automatically. Returns {cardId, envelope, branch, scope, stoppingPoint}.",
|
|
2880
|
+
parameters: {
|
|
2881
|
+
type: "object",
|
|
2882
|
+
additionalProperties: false,
|
|
2883
|
+
properties: {
|
|
2884
|
+
role: { type: "string", description: "The role claiming the card; must be declared in the automation policy." },
|
|
2885
|
+
cardId: { type: "string", description: "Optional: claim this specific card instead of the highest-priority dispatchable one." },
|
|
2886
|
+
},
|
|
2887
|
+
required: ["role"],
|
|
2888
|
+
},
|
|
2889
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
2890
|
+
const activeBoardPath = boardPathFor(ctx);
|
|
2891
|
+
if (!activeBoardPath || !existsSync(activeBoardPath)) {
|
|
2892
|
+
return unavailableResult();
|
|
2893
|
+
}
|
|
2894
|
+
if (typeof input?.role !== "string" || input.role === "") {
|
|
2895
|
+
const value = { ok: false, code: "invalid-input", reason: "role is required", errors: ["role is required"] };
|
|
2896
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
2897
|
+
}
|
|
2898
|
+
let result;
|
|
2899
|
+
try {
|
|
2900
|
+
result = claimCard({ boardPath: activeBoardPath, role: input.role, cardId: input?.cardId ?? null });
|
|
2901
|
+
} catch (error) {
|
|
2902
|
+
const code = typeof error?.code === "string" && error.code !== "claims-corrupt" ? error.code : error.code;
|
|
2903
|
+
const value = { ok: false, code, reason: String(error?.message || error).slice(0, 512), errors: [String(error?.message || error).slice(0, 512)] };
|
|
2904
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
2905
|
+
}
|
|
2906
|
+
let value;
|
|
2907
|
+
if (result.ok) {
|
|
2908
|
+
value = {
|
|
2909
|
+
ok: true,
|
|
2910
|
+
claimed: true,
|
|
2911
|
+
cardId: result.cardId,
|
|
2912
|
+
envelope: result.envelope,
|
|
2913
|
+
branch: result.envelope.branch,
|
|
2914
|
+
scope: result.envelope.allowedPaths,
|
|
2915
|
+
stoppingPoint: result.envelope.stoppingPoint,
|
|
2916
|
+
role: result.role,
|
|
2917
|
+
};
|
|
2918
|
+
} else {
|
|
2919
|
+
value = { ok: false, code: result.code, reason: result.reason ?? (result.errors ?? []).join("; "), errors: result.errors ?? [] };
|
|
2920
|
+
}
|
|
2921
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
2922
|
+
},
|
|
2923
|
+
});
|
|
2924
|
+
registered.push("agentic_kanban_board_dispatch");
|
|
2925
|
+
}
|
|
1598
2926
|
return { registered, observation: { ...observation, boardPath: observation.boardPath } };
|
|
1599
2927
|
}
|