@cadraos/sdk 0.3.7 → 0.3.9
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/dist/board-host/index.d.cts +74 -6
- package/dist/board-host/index.d.ts +74 -6
- package/dist/board-host/server/index.d.cts +81 -6
- package/dist/board-host/server/index.d.ts +81 -6
- package/dist/chat/index.cjs +4 -43
- package/dist/chat/index.cjs.map +1 -1
- package/dist/chat/index.d.cts +7 -0
- package/dist/chat/index.d.ts +7 -0
- package/dist/chat/index.js +4 -43
- package/dist/chat/index.js.map +1 -1
- package/dist/index.cjs +4 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -43
- package/dist/index.js.map +1 -1
- package/dist/kanban/engine/index.cjs +119 -31
- package/dist/kanban/engine/index.cjs.map +1 -1
- package/dist/kanban/engine/index.d.cts +112 -6
- package/dist/kanban/engine/index.d.ts +112 -6
- package/dist/kanban/engine/index.js +119 -31
- package/dist/kanban/engine/index.js.map +1 -1
- package/dist/kanban/index.cjs +574 -155
- package/dist/kanban/index.cjs.map +1 -1
- package/dist/kanban/index.d.cts +277 -11
- package/dist/kanban/index.d.ts +277 -11
- package/dist/kanban/index.js +569 -156
- package/dist/kanban/index.js.map +1 -1
- package/dist/teams/index.cjs +4 -43
- package/dist/teams/index.cjs.map +1 -1
- package/dist/teams/index.d.cts +7 -0
- package/dist/teams/index.d.ts +7 -0
- package/dist/teams/index.js +4 -43
- package/dist/teams/index.js.map +1 -1
- package/package.json +16 -15
|
@@ -315,6 +315,13 @@ interface KanbanCard {
|
|
|
315
315
|
executionId?: string;
|
|
316
316
|
/** Summary of the card's current/last run, if any. */
|
|
317
317
|
run?: KanbanRunSummary;
|
|
318
|
+
/**
|
|
319
|
+
* When this card next becomes DUE on a scheduled lane (ISO string) — maps to
|
|
320
|
+
* `agentic_card.next_due_at` (spec §3a / STORY-003/004). Drives the board's
|
|
321
|
+
* ⧗ "due" chip: `next_due_at ≤ now` → due (the next sweep will pick it up).
|
|
322
|
+
* OPTIONAL — absent → no due chip (today's render).
|
|
323
|
+
*/
|
|
324
|
+
nextDueAt?: string;
|
|
318
325
|
/** Who created the card. */
|
|
319
326
|
createdBy?: string;
|
|
320
327
|
/** When the card was created (ISO string). */
|
|
@@ -411,6 +418,13 @@ type StageRunStatus = "queued" | "running" | "completed" | "failed";
|
|
|
411
418
|
* dispatch or a duplicate completion can't double-fire / double-advance
|
|
412
419
|
* (design §5/§11 R2), and a `key` rename never collides with a prior run.
|
|
413
420
|
* Backed by a host `stage_run` table with a UNIQUE constraint on `idemKey`.
|
|
421
|
+
*
|
|
422
|
+
* Stranded-run recovery (design §10): a run left `failed` (a dispatch threw) or
|
|
423
|
+
* stale `queued` (a process died before dispatch) is RE-CLAIMED by the next
|
|
424
|
+
* `beginStageRun` — reset to a fresh `queued` intent (with `attempts` bumped) —
|
|
425
|
+
* so a transient dispatch failure self-heals on the following sweep. v1 is
|
|
426
|
+
* acyclic-only: the idemKey pins `(cardId, stageId, version)`, so a re-claim
|
|
427
|
+
* only ever re-runs a FAILED attempt of the current stage, never a completed one.
|
|
414
428
|
*/
|
|
415
429
|
interface StageRun {
|
|
416
430
|
/** Idempotency key (`cardId:stageId:configVersion`; stageId = immutable id). UNIQUE. */
|
|
@@ -427,6 +441,19 @@ interface StageRun {
|
|
|
427
441
|
executionUuid?: string;
|
|
428
442
|
/** Error message (when failed). */
|
|
429
443
|
error?: string;
|
|
444
|
+
/**
|
|
445
|
+
* Dispatch attempt count (design §10 — stranded-run recovery). `1` on first
|
|
446
|
+
* claim; incremented each time `beginStageRun` RE-CLAIMS a stranded
|
|
447
|
+
* (failed/stale-queued) run. Optional/back-compat — a host that doesn't track
|
|
448
|
+
* re-claims leaves it undefined; surfaced for the sweep digest + observability.
|
|
449
|
+
*
|
|
450
|
+
* v1 RECORDS attempts but does NOT cap them: self-heal is bounded by the
|
|
451
|
+
* CADENCE (one re-dispatch per sweep tick), not a max-attempts ceiling, so a
|
|
452
|
+
* transient outage recovers while a genuinely poison card simply re-fails once
|
|
453
|
+
* per tick (visible via this counter). A retry ceiling / backoff is a
|
|
454
|
+
* deliberate follow-on, not v1.
|
|
455
|
+
*/
|
|
456
|
+
attempts?: number;
|
|
430
457
|
/** When the run intent was created (ISO string). */
|
|
431
458
|
createdAt: string;
|
|
432
459
|
}
|
|
@@ -480,7 +507,12 @@ interface ApplyTransitionInput {
|
|
|
480
507
|
*
|
|
481
508
|
* 2. `beginStageRun` is a CAS insert keyed on `idemKey` (UNIQUE). It returns
|
|
482
509
|
* `{ created: true, run }` on first insert and `{ created: false, run }`
|
|
483
|
-
* when
|
|
510
|
+
* when a NON-reclaimable run already exists — so a retried dispatch is a
|
|
511
|
+
* no-op. A `failed` or STALE `queued` run is RE-CLAIMED (reset to `queued`
|
|
512
|
+
* → `{ created: true }`) so a transient dispatch failure self-heals on the
|
|
513
|
+
* next sweep (design §10 — stranded-run recovery); a `running`/`completed`
|
|
514
|
+
* run is never re-claimed. v1 is acyclic-only: the idemKey pins
|
|
515
|
+
* `(cardId, stageId, version)`, so a re-claim only re-runs a FAILED attempt.
|
|
484
516
|
*
|
|
485
517
|
* 3. `completeStageRun` runs in ONE transaction: mark the stage run completed
|
|
486
518
|
* AND `applyTransition` to `toLane` together; it returns `{ applied: false }`
|
|
@@ -594,11 +626,42 @@ interface KanbanStorageAdapter {
|
|
|
594
626
|
*/
|
|
595
627
|
capabilities?(): AdapterCapabilities;
|
|
596
628
|
/**
|
|
597
|
-
* CAS-insert a stage-run intent keyed on `idemKey` (UNIQUE)
|
|
629
|
+
* CAS-insert a stage-run intent keyed on `idemKey` (UNIQUE), with
|
|
630
|
+
* FAILED/STALE-QUEUED RE-CLAIM (design §10 — stranded-run recovery).
|
|
631
|
+
*
|
|
632
|
+
* @returns `{ created: true, run }` when this call OWNS the dispatch — either
|
|
633
|
+
* a first insert, OR a RE-CLAIM of an existing run left `failed` (a prior
|
|
634
|
+
* dispatch threw → `failStageRun`) or stale `queued` (a process died between
|
|
635
|
+
* begin and dispatch): the adapter resets that run to a fresh `queued` intent
|
|
636
|
+
* (clearing the stale error/executionUuid, bumping `attempts`) and the engine
|
|
637
|
+
* dispatches again. `{ created: false, run }` when a NON-reclaimable run
|
|
638
|
+
* already exists (`running` — mid-flight; or `completed`) — the engine treats
|
|
639
|
+
* this as "already dispatched"/terminal and does NOT re-invoke the handler.
|
|
640
|
+
*
|
|
641
|
+
* A host implements the re-claim as a single conditional write (e.g. an
|
|
642
|
+
* `UPDATE stage_run SET status='queued', attempts=attempts+1, created_at=now(),
|
|
643
|
+
* error=NULL, execution_uuid=NULL WHERE idem_key=$1 AND (status='failed' OR
|
|
644
|
+
* (status='queued' AND created_at < $stale)) RETURNING …`, alongside the plain
|
|
645
|
+
* CAS insert).
|
|
598
646
|
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
647
|
+
* ⚠ CRITICAL for the "two overlapping ticks never both win" guarantee: the SET
|
|
648
|
+
* MUST move the row OUT of its own WHERE range — reset the `created_at`
|
|
649
|
+
* discriminator (as shown). Under READ COMMITTED, a concurrent tick's UPDATE
|
|
650
|
+
* blocks on the row lock, then RE-CHECKS its WHERE against the just-committed
|
|
651
|
+
* row: with `created_at=now()` the stale-queued predicate no longer matches, so
|
|
652
|
+
* it updates 0 rows (RETURNING empty → `created:false`). A naive
|
|
653
|
+
* `SET status='queued'` that leaves `created_at` UNCHANGED is the trap — both
|
|
654
|
+
* ticks still match the same stale `queued` row and BOTH re-dispatch. (The
|
|
655
|
+
* in-memory reference adapter resets `createdAt` for exactly this reason.)
|
|
656
|
+
* Equivalently, claim via a `SELECT … FOR UPDATE SKIP LOCKED` subselect. The
|
|
657
|
+
* staleness horizon MUST comfortably exceed the host's dispatch timeout so a
|
|
658
|
+
* genuinely in-flight `queued` row (mid dispatch call) is never re-claimed out
|
|
659
|
+
* from under it.
|
|
660
|
+
*
|
|
661
|
+
* v1 scope is ACYCLIC flows only: because the idemKey embeds
|
|
662
|
+
* `(cardId, stageId, version)`, a card can never legitimately re-enter the same
|
|
663
|
+
* stage at the same version, so a re-claim only ever re-runs a FAILED attempt
|
|
664
|
+
* of the current stage — it never re-executes a completed one.
|
|
602
665
|
*/
|
|
603
666
|
beginStageRun(cardId: string, r: {
|
|
604
667
|
stageId: string;
|
|
@@ -625,7 +688,12 @@ interface KanbanStorageAdapter {
|
|
|
625
688
|
applied: boolean;
|
|
626
689
|
}>;
|
|
627
690
|
/**
|
|
628
|
-
* Mark a stage run failed (surfaced
|
|
691
|
+
* Mark a stage run failed (surfaced). Does NOT advance the card — it stays in
|
|
692
|
+
* its lane. The engine calls this when a stage DISPATCH throws (design §10 —
|
|
693
|
+
* stranded-run recovery), so the run is left `failed` (NOT a stranded `queued`
|
|
694
|
+
* row the CAS would skip forever) and is RE-CLAIMED by the next
|
|
695
|
+
* `beginStageRun` on the following sweep. A `completed` run is never clobbered
|
|
696
|
+
* (a late failure after a success is a no-op — idempotent).
|
|
629
697
|
*/
|
|
630
698
|
failStageRun(idemKey: string, f: {
|
|
631
699
|
error: string;
|
|
@@ -805,6 +873,13 @@ interface WireHandler {
|
|
|
805
873
|
type?: string;
|
|
806
874
|
agent_uuid?: string;
|
|
807
875
|
team_uuid?: string;
|
|
876
|
+
/**
|
|
877
|
+
* remote_agent → the `remote_agents.uuid` for a `remote_agent` lane (§5.3).
|
|
878
|
+
* Snake-cased on the wire (published JSONB); mapped to `remoteAgentUuid`.
|
|
879
|
+
* WITHOUT this the binding is silently dropped at parse and every published
|
|
880
|
+
* remote_agent lane fails dispatch with "missing remoteAgentUuid" (FINDING-6).
|
|
881
|
+
*/
|
|
882
|
+
remote_agent_uuid?: string;
|
|
808
883
|
/** Legacy p28 slug ref. */
|
|
809
884
|
agent_ref?: string;
|
|
810
885
|
webhook?: {
|
|
@@ -994,6 +1069,14 @@ interface RunAgentInput {
|
|
|
994
1069
|
input: Record<string, unknown>;
|
|
995
1070
|
/** Idempotency key (= cardId:stageId:version). */
|
|
996
1071
|
idemKey: string;
|
|
1072
|
+
/**
|
|
1073
|
+
* Board/headless dispatch forces allowAskUser=false (spec §6.5 / FINDING-5).
|
|
1074
|
+
* The engine only ever dispatches cadra_agent stages from board surfaces,
|
|
1075
|
+
* which have NO ask_user UI — an ask attempt must FAIL the execution (→ failed
|
|
1076
|
+
* callback → completion bridge → §10 reclaim), never pause it. Pinned `false`
|
|
1077
|
+
* at the type boundary, matching RunRemoteAgentInput.
|
|
1078
|
+
*/
|
|
1079
|
+
allowAskUser: false;
|
|
997
1080
|
/** A host callback reference the run can advance through (optional). */
|
|
998
1081
|
callbackRef?: string;
|
|
999
1082
|
}
|
|
@@ -1003,6 +1086,8 @@ interface RunTeamInput {
|
|
|
1003
1086
|
orgId?: number;
|
|
1004
1087
|
input: Record<string, unknown>;
|
|
1005
1088
|
idemKey: string;
|
|
1089
|
+
/** Board/headless dispatch forces allowAskUser=false (spec §6.5 / FINDING-5). */
|
|
1090
|
+
allowAskUser: false;
|
|
1006
1091
|
callbackRef?: string;
|
|
1007
1092
|
}
|
|
1008
1093
|
interface RunRemoteAgentInput {
|
|
@@ -1196,6 +1281,20 @@ declare class InMemoryStorageAdapter implements KanbanStorageAdapter {
|
|
|
1196
1281
|
private cards;
|
|
1197
1282
|
private logs;
|
|
1198
1283
|
private runs;
|
|
1284
|
+
/**
|
|
1285
|
+
* A `queued` stage-run older than this (ms) is STALE and re-claimable by
|
|
1286
|
+
* `beginStageRun` (design §10) — it models a run whose process died between
|
|
1287
|
+
* `beginStageRun` and dispatch, so `failStageRun` never ran. A `failed` run is
|
|
1288
|
+
* re-claimable regardless of age. Must comfortably exceed the dispatch time so
|
|
1289
|
+
* a genuinely in-flight `queued` run is never re-claimed out from under it.
|
|
1290
|
+
*/
|
|
1291
|
+
private readonly staleQueuedMs;
|
|
1292
|
+
/** Injectable clock (ms) — real hosts use `Date.now`; tests pin it. */
|
|
1293
|
+
private readonly now;
|
|
1294
|
+
constructor(opts?: {
|
|
1295
|
+
staleQueuedMs?: number;
|
|
1296
|
+
now?: () => number;
|
|
1297
|
+
});
|
|
1199
1298
|
listCards(boardConfigUuid: string): Promise<KanbanCard[]>;
|
|
1200
1299
|
getCard(cardId: string): Promise<KanbanCard | null>;
|
|
1201
1300
|
createCard(input: CreateCardServerInput): Promise<KanbanCard>;
|
|
@@ -1219,6 +1318,13 @@ declare class InMemoryStorageAdapter implements KanbanStorageAdapter {
|
|
|
1219
1318
|
created: boolean;
|
|
1220
1319
|
run: StageRun;
|
|
1221
1320
|
}>;
|
|
1321
|
+
/**
|
|
1322
|
+
* A stranded stage-run the next `beginStageRun` may re-claim (design §10): a
|
|
1323
|
+
* `failed` run (dispatch threw), or a `queued` run older than `staleQueuedMs`
|
|
1324
|
+
* (a process died before dispatch flipped it). `running`/`completed` runs are
|
|
1325
|
+
* never re-claimed.
|
|
1326
|
+
*/
|
|
1327
|
+
private isReclaimable;
|
|
1222
1328
|
completeStageRun(idemKey: string, c: {
|
|
1223
1329
|
executionUuid?: string;
|
|
1224
1330
|
toLane?: string;
|
|
@@ -315,6 +315,13 @@ interface KanbanCard {
|
|
|
315
315
|
executionId?: string;
|
|
316
316
|
/** Summary of the card's current/last run, if any. */
|
|
317
317
|
run?: KanbanRunSummary;
|
|
318
|
+
/**
|
|
319
|
+
* When this card next becomes DUE on a scheduled lane (ISO string) — maps to
|
|
320
|
+
* `agentic_card.next_due_at` (spec §3a / STORY-003/004). Drives the board's
|
|
321
|
+
* ⧗ "due" chip: `next_due_at ≤ now` → due (the next sweep will pick it up).
|
|
322
|
+
* OPTIONAL — absent → no due chip (today's render).
|
|
323
|
+
*/
|
|
324
|
+
nextDueAt?: string;
|
|
318
325
|
/** Who created the card. */
|
|
319
326
|
createdBy?: string;
|
|
320
327
|
/** When the card was created (ISO string). */
|
|
@@ -411,6 +418,13 @@ type StageRunStatus = "queued" | "running" | "completed" | "failed";
|
|
|
411
418
|
* dispatch or a duplicate completion can't double-fire / double-advance
|
|
412
419
|
* (design §5/§11 R2), and a `key` rename never collides with a prior run.
|
|
413
420
|
* Backed by a host `stage_run` table with a UNIQUE constraint on `idemKey`.
|
|
421
|
+
*
|
|
422
|
+
* Stranded-run recovery (design §10): a run left `failed` (a dispatch threw) or
|
|
423
|
+
* stale `queued` (a process died before dispatch) is RE-CLAIMED by the next
|
|
424
|
+
* `beginStageRun` — reset to a fresh `queued` intent (with `attempts` bumped) —
|
|
425
|
+
* so a transient dispatch failure self-heals on the following sweep. v1 is
|
|
426
|
+
* acyclic-only: the idemKey pins `(cardId, stageId, version)`, so a re-claim
|
|
427
|
+
* only ever re-runs a FAILED attempt of the current stage, never a completed one.
|
|
414
428
|
*/
|
|
415
429
|
interface StageRun {
|
|
416
430
|
/** Idempotency key (`cardId:stageId:configVersion`; stageId = immutable id). UNIQUE. */
|
|
@@ -427,6 +441,19 @@ interface StageRun {
|
|
|
427
441
|
executionUuid?: string;
|
|
428
442
|
/** Error message (when failed). */
|
|
429
443
|
error?: string;
|
|
444
|
+
/**
|
|
445
|
+
* Dispatch attempt count (design §10 — stranded-run recovery). `1` on first
|
|
446
|
+
* claim; incremented each time `beginStageRun` RE-CLAIMS a stranded
|
|
447
|
+
* (failed/stale-queued) run. Optional/back-compat — a host that doesn't track
|
|
448
|
+
* re-claims leaves it undefined; surfaced for the sweep digest + observability.
|
|
449
|
+
*
|
|
450
|
+
* v1 RECORDS attempts but does NOT cap them: self-heal is bounded by the
|
|
451
|
+
* CADENCE (one re-dispatch per sweep tick), not a max-attempts ceiling, so a
|
|
452
|
+
* transient outage recovers while a genuinely poison card simply re-fails once
|
|
453
|
+
* per tick (visible via this counter). A retry ceiling / backoff is a
|
|
454
|
+
* deliberate follow-on, not v1.
|
|
455
|
+
*/
|
|
456
|
+
attempts?: number;
|
|
430
457
|
/** When the run intent was created (ISO string). */
|
|
431
458
|
createdAt: string;
|
|
432
459
|
}
|
|
@@ -480,7 +507,12 @@ interface ApplyTransitionInput {
|
|
|
480
507
|
*
|
|
481
508
|
* 2. `beginStageRun` is a CAS insert keyed on `idemKey` (UNIQUE). It returns
|
|
482
509
|
* `{ created: true, run }` on first insert and `{ created: false, run }`
|
|
483
|
-
* when
|
|
510
|
+
* when a NON-reclaimable run already exists — so a retried dispatch is a
|
|
511
|
+
* no-op. A `failed` or STALE `queued` run is RE-CLAIMED (reset to `queued`
|
|
512
|
+
* → `{ created: true }`) so a transient dispatch failure self-heals on the
|
|
513
|
+
* next sweep (design §10 — stranded-run recovery); a `running`/`completed`
|
|
514
|
+
* run is never re-claimed. v1 is acyclic-only: the idemKey pins
|
|
515
|
+
* `(cardId, stageId, version)`, so a re-claim only re-runs a FAILED attempt.
|
|
484
516
|
*
|
|
485
517
|
* 3. `completeStageRun` runs in ONE transaction: mark the stage run completed
|
|
486
518
|
* AND `applyTransition` to `toLane` together; it returns `{ applied: false }`
|
|
@@ -594,11 +626,42 @@ interface KanbanStorageAdapter {
|
|
|
594
626
|
*/
|
|
595
627
|
capabilities?(): AdapterCapabilities;
|
|
596
628
|
/**
|
|
597
|
-
* CAS-insert a stage-run intent keyed on `idemKey` (UNIQUE)
|
|
629
|
+
* CAS-insert a stage-run intent keyed on `idemKey` (UNIQUE), with
|
|
630
|
+
* FAILED/STALE-QUEUED RE-CLAIM (design §10 — stranded-run recovery).
|
|
631
|
+
*
|
|
632
|
+
* @returns `{ created: true, run }` when this call OWNS the dispatch — either
|
|
633
|
+
* a first insert, OR a RE-CLAIM of an existing run left `failed` (a prior
|
|
634
|
+
* dispatch threw → `failStageRun`) or stale `queued` (a process died between
|
|
635
|
+
* begin and dispatch): the adapter resets that run to a fresh `queued` intent
|
|
636
|
+
* (clearing the stale error/executionUuid, bumping `attempts`) and the engine
|
|
637
|
+
* dispatches again. `{ created: false, run }` when a NON-reclaimable run
|
|
638
|
+
* already exists (`running` — mid-flight; or `completed`) — the engine treats
|
|
639
|
+
* this as "already dispatched"/terminal and does NOT re-invoke the handler.
|
|
640
|
+
*
|
|
641
|
+
* A host implements the re-claim as a single conditional write (e.g. an
|
|
642
|
+
* `UPDATE stage_run SET status='queued', attempts=attempts+1, created_at=now(),
|
|
643
|
+
* error=NULL, execution_uuid=NULL WHERE idem_key=$1 AND (status='failed' OR
|
|
644
|
+
* (status='queued' AND created_at < $stale)) RETURNING …`, alongside the plain
|
|
645
|
+
* CAS insert).
|
|
598
646
|
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
647
|
+
* ⚠ CRITICAL for the "two overlapping ticks never both win" guarantee: the SET
|
|
648
|
+
* MUST move the row OUT of its own WHERE range — reset the `created_at`
|
|
649
|
+
* discriminator (as shown). Under READ COMMITTED, a concurrent tick's UPDATE
|
|
650
|
+
* blocks on the row lock, then RE-CHECKS its WHERE against the just-committed
|
|
651
|
+
* row: with `created_at=now()` the stale-queued predicate no longer matches, so
|
|
652
|
+
* it updates 0 rows (RETURNING empty → `created:false`). A naive
|
|
653
|
+
* `SET status='queued'` that leaves `created_at` UNCHANGED is the trap — both
|
|
654
|
+
* ticks still match the same stale `queued` row and BOTH re-dispatch. (The
|
|
655
|
+
* in-memory reference adapter resets `createdAt` for exactly this reason.)
|
|
656
|
+
* Equivalently, claim via a `SELECT … FOR UPDATE SKIP LOCKED` subselect. The
|
|
657
|
+
* staleness horizon MUST comfortably exceed the host's dispatch timeout so a
|
|
658
|
+
* genuinely in-flight `queued` row (mid dispatch call) is never re-claimed out
|
|
659
|
+
* from under it.
|
|
660
|
+
*
|
|
661
|
+
* v1 scope is ACYCLIC flows only: because the idemKey embeds
|
|
662
|
+
* `(cardId, stageId, version)`, a card can never legitimately re-enter the same
|
|
663
|
+
* stage at the same version, so a re-claim only ever re-runs a FAILED attempt
|
|
664
|
+
* of the current stage — it never re-executes a completed one.
|
|
602
665
|
*/
|
|
603
666
|
beginStageRun(cardId: string, r: {
|
|
604
667
|
stageId: string;
|
|
@@ -625,7 +688,12 @@ interface KanbanStorageAdapter {
|
|
|
625
688
|
applied: boolean;
|
|
626
689
|
}>;
|
|
627
690
|
/**
|
|
628
|
-
* Mark a stage run failed (surfaced
|
|
691
|
+
* Mark a stage run failed (surfaced). Does NOT advance the card — it stays in
|
|
692
|
+
* its lane. The engine calls this when a stage DISPATCH throws (design §10 —
|
|
693
|
+
* stranded-run recovery), so the run is left `failed` (NOT a stranded `queued`
|
|
694
|
+
* row the CAS would skip forever) and is RE-CLAIMED by the next
|
|
695
|
+
* `beginStageRun` on the following sweep. A `completed` run is never clobbered
|
|
696
|
+
* (a late failure after a success is a no-op — idempotent).
|
|
629
697
|
*/
|
|
630
698
|
failStageRun(idemKey: string, f: {
|
|
631
699
|
error: string;
|
|
@@ -805,6 +873,13 @@ interface WireHandler {
|
|
|
805
873
|
type?: string;
|
|
806
874
|
agent_uuid?: string;
|
|
807
875
|
team_uuid?: string;
|
|
876
|
+
/**
|
|
877
|
+
* remote_agent → the `remote_agents.uuid` for a `remote_agent` lane (§5.3).
|
|
878
|
+
* Snake-cased on the wire (published JSONB); mapped to `remoteAgentUuid`.
|
|
879
|
+
* WITHOUT this the binding is silently dropped at parse and every published
|
|
880
|
+
* remote_agent lane fails dispatch with "missing remoteAgentUuid" (FINDING-6).
|
|
881
|
+
*/
|
|
882
|
+
remote_agent_uuid?: string;
|
|
808
883
|
/** Legacy p28 slug ref. */
|
|
809
884
|
agent_ref?: string;
|
|
810
885
|
webhook?: {
|
|
@@ -994,6 +1069,14 @@ interface RunAgentInput {
|
|
|
994
1069
|
input: Record<string, unknown>;
|
|
995
1070
|
/** Idempotency key (= cardId:stageId:version). */
|
|
996
1071
|
idemKey: string;
|
|
1072
|
+
/**
|
|
1073
|
+
* Board/headless dispatch forces allowAskUser=false (spec §6.5 / FINDING-5).
|
|
1074
|
+
* The engine only ever dispatches cadra_agent stages from board surfaces,
|
|
1075
|
+
* which have NO ask_user UI — an ask attempt must FAIL the execution (→ failed
|
|
1076
|
+
* callback → completion bridge → §10 reclaim), never pause it. Pinned `false`
|
|
1077
|
+
* at the type boundary, matching RunRemoteAgentInput.
|
|
1078
|
+
*/
|
|
1079
|
+
allowAskUser: false;
|
|
997
1080
|
/** A host callback reference the run can advance through (optional). */
|
|
998
1081
|
callbackRef?: string;
|
|
999
1082
|
}
|
|
@@ -1003,6 +1086,8 @@ interface RunTeamInput {
|
|
|
1003
1086
|
orgId?: number;
|
|
1004
1087
|
input: Record<string, unknown>;
|
|
1005
1088
|
idemKey: string;
|
|
1089
|
+
/** Board/headless dispatch forces allowAskUser=false (spec §6.5 / FINDING-5). */
|
|
1090
|
+
allowAskUser: false;
|
|
1006
1091
|
callbackRef?: string;
|
|
1007
1092
|
}
|
|
1008
1093
|
interface RunRemoteAgentInput {
|
|
@@ -1196,6 +1281,20 @@ declare class InMemoryStorageAdapter implements KanbanStorageAdapter {
|
|
|
1196
1281
|
private cards;
|
|
1197
1282
|
private logs;
|
|
1198
1283
|
private runs;
|
|
1284
|
+
/**
|
|
1285
|
+
* A `queued` stage-run older than this (ms) is STALE and re-claimable by
|
|
1286
|
+
* `beginStageRun` (design §10) — it models a run whose process died between
|
|
1287
|
+
* `beginStageRun` and dispatch, so `failStageRun` never ran. A `failed` run is
|
|
1288
|
+
* re-claimable regardless of age. Must comfortably exceed the dispatch time so
|
|
1289
|
+
* a genuinely in-flight `queued` run is never re-claimed out from under it.
|
|
1290
|
+
*/
|
|
1291
|
+
private readonly staleQueuedMs;
|
|
1292
|
+
/** Injectable clock (ms) — real hosts use `Date.now`; tests pin it. */
|
|
1293
|
+
private readonly now;
|
|
1294
|
+
constructor(opts?: {
|
|
1295
|
+
staleQueuedMs?: number;
|
|
1296
|
+
now?: () => number;
|
|
1297
|
+
});
|
|
1199
1298
|
listCards(boardConfigUuid: string): Promise<KanbanCard[]>;
|
|
1200
1299
|
getCard(cardId: string): Promise<KanbanCard | null>;
|
|
1201
1300
|
createCard(input: CreateCardServerInput): Promise<KanbanCard>;
|
|
@@ -1219,6 +1318,13 @@ declare class InMemoryStorageAdapter implements KanbanStorageAdapter {
|
|
|
1219
1318
|
created: boolean;
|
|
1220
1319
|
run: StageRun;
|
|
1221
1320
|
}>;
|
|
1321
|
+
/**
|
|
1322
|
+
* A stranded stage-run the next `beginStageRun` may re-claim (design §10): a
|
|
1323
|
+
* `failed` run (dispatch threw), or a `queued` run older than `staleQueuedMs`
|
|
1324
|
+
* (a process died before dispatch flipped it). `running`/`completed` runs are
|
|
1325
|
+
* never re-claimed.
|
|
1326
|
+
*/
|
|
1327
|
+
private isReclaimable;
|
|
1222
1328
|
completeStageRun(idemKey: string, c: {
|
|
1223
1329
|
executionUuid?: string;
|
|
1224
1330
|
toLane?: string;
|
|
@@ -10,6 +10,10 @@ function mapHandler(h) {
|
|
|
10
10
|
type: h.type,
|
|
11
11
|
agentUuid: h.agent_uuid,
|
|
12
12
|
teamUuid: h.team_uuid,
|
|
13
|
+
// FINDING-6: carry the remote_agent binding through the wire parse. The
|
|
14
|
+
// engine's remote_agent dispatch reads `remoteAgentUuid`; dropping it here
|
|
15
|
+
// made every published remote_agent lane dispatch-dead.
|
|
16
|
+
remoteAgentUuid: h.remote_agent_uuid,
|
|
13
17
|
agentRef: h.agent_ref,
|
|
14
18
|
webhook: h.webhook ? {
|
|
15
19
|
url: h.webhook.url,
|
|
@@ -217,6 +221,8 @@ var WebhookDeliveryError = class extends Error {
|
|
|
217
221
|
this.retryable = retryable;
|
|
218
222
|
this.name = "WebhookDeliveryError";
|
|
219
223
|
}
|
|
224
|
+
status;
|
|
225
|
+
retryable;
|
|
220
226
|
};
|
|
221
227
|
async function hmacSign(secret, payload) {
|
|
222
228
|
const enc = new TextEncoder();
|
|
@@ -319,6 +325,8 @@ async function dispatchStageHandler(ctx, cadra, webhookOpts) {
|
|
|
319
325
|
orgId: ctx.orgId,
|
|
320
326
|
input: ctx.input,
|
|
321
327
|
idemKey: runIdemKey,
|
|
328
|
+
// Board/headless dispatch always disables ask_user (spec §6.5 / FINDING-5).
|
|
329
|
+
allowAskUser: false,
|
|
322
330
|
callbackRef: ctx.callbackUrl
|
|
323
331
|
});
|
|
324
332
|
return { dispatched: true, executionUuid };
|
|
@@ -332,6 +340,8 @@ async function dispatchStageHandler(ctx, cadra, webhookOpts) {
|
|
|
332
340
|
orgId: ctx.orgId,
|
|
333
341
|
input: ctx.input,
|
|
334
342
|
idemKey: runIdemKey,
|
|
343
|
+
// Board/headless dispatch always disables ask_user (spec §6.5 / FINDING-5).
|
|
344
|
+
allowAskUser: false,
|
|
335
345
|
callbackRef: ctx.callbackUrl
|
|
336
346
|
});
|
|
337
347
|
return { dispatched: true, executionUuid };
|
|
@@ -382,10 +392,25 @@ function nextId(prefix) {
|
|
|
382
392
|
idSeq += 1;
|
|
383
393
|
return `${prefix}-${idSeq}`;
|
|
384
394
|
}
|
|
395
|
+
var DEFAULT_STALE_QUEUED_MS = 15 * 6e4;
|
|
385
396
|
var InMemoryStorageAdapter = class {
|
|
386
397
|
cards = /* @__PURE__ */ new Map();
|
|
387
398
|
logs = /* @__PURE__ */ new Map();
|
|
388
399
|
runs = /* @__PURE__ */ new Map();
|
|
400
|
+
/**
|
|
401
|
+
* A `queued` stage-run older than this (ms) is STALE and re-claimable by
|
|
402
|
+
* `beginStageRun` (design §10) — it models a run whose process died between
|
|
403
|
+
* `beginStageRun` and dispatch, so `failStageRun` never ran. A `failed` run is
|
|
404
|
+
* re-claimable regardless of age. Must comfortably exceed the dispatch time so
|
|
405
|
+
* a genuinely in-flight `queued` run is never re-claimed out from under it.
|
|
406
|
+
*/
|
|
407
|
+
staleQueuedMs;
|
|
408
|
+
/** Injectable clock (ms) — real hosts use `Date.now`; tests pin it. */
|
|
409
|
+
now;
|
|
410
|
+
constructor(opts) {
|
|
411
|
+
this.staleQueuedMs = opts?.staleQueuedMs ?? DEFAULT_STALE_QUEUED_MS;
|
|
412
|
+
this.now = opts?.now ?? (() => Date.now());
|
|
413
|
+
}
|
|
389
414
|
// ----- Cards -----
|
|
390
415
|
async listCards(boardConfigUuid) {
|
|
391
416
|
return [...this.cards.values()].filter((c) => c.boardConfigUuid === boardConfigUuid).map((c) => this.publicCard(c));
|
|
@@ -485,18 +510,48 @@ var InMemoryStorageAdapter = class {
|
|
|
485
510
|
// ----- Stage runs -----
|
|
486
511
|
async beginStageRun(cardId, r) {
|
|
487
512
|
const existing = this.runs.get(r.idemKey);
|
|
488
|
-
if (existing)
|
|
513
|
+
if (existing) {
|
|
514
|
+
if (this.isReclaimable(existing)) {
|
|
515
|
+
const run2 = {
|
|
516
|
+
idemKey: r.idemKey,
|
|
517
|
+
cardId,
|
|
518
|
+
stageId: r.stageId,
|
|
519
|
+
handlerType: r.handlerType,
|
|
520
|
+
status: "queued",
|
|
521
|
+
attempts: (existing.attempts ?? 1) + 1,
|
|
522
|
+
createdAt: new Date(this.now()).toISOString()
|
|
523
|
+
};
|
|
524
|
+
this.runs.set(r.idemKey, run2);
|
|
525
|
+
return { created: true, run: { ...run2 } };
|
|
526
|
+
}
|
|
527
|
+
return { created: false, run: { ...existing } };
|
|
528
|
+
}
|
|
489
529
|
const run = {
|
|
490
530
|
idemKey: r.idemKey,
|
|
491
531
|
cardId,
|
|
492
532
|
stageId: r.stageId,
|
|
493
533
|
handlerType: r.handlerType,
|
|
494
534
|
status: "queued",
|
|
495
|
-
|
|
535
|
+
attempts: 1,
|
|
536
|
+
createdAt: new Date(this.now()).toISOString()
|
|
496
537
|
};
|
|
497
538
|
this.runs.set(r.idemKey, run);
|
|
498
539
|
return { created: true, run: { ...run } };
|
|
499
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* A stranded stage-run the next `beginStageRun` may re-claim (design §10): a
|
|
543
|
+
* `failed` run (dispatch threw), or a `queued` run older than `staleQueuedMs`
|
|
544
|
+
* (a process died before dispatch flipped it). `running`/`completed` runs are
|
|
545
|
+
* never re-claimed.
|
|
546
|
+
*/
|
|
547
|
+
isReclaimable(run) {
|
|
548
|
+
if (run.status === "failed") return true;
|
|
549
|
+
if (run.status === "queued") {
|
|
550
|
+
const age = this.now() - Date.parse(run.createdAt);
|
|
551
|
+
return Number.isFinite(age) && age >= this.staleQueuedMs;
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
500
555
|
async completeStageRun(idemKey, c) {
|
|
501
556
|
const run = this.runs.get(idemKey);
|
|
502
557
|
if (!run) throw new Error(`No stage run for idemKey ${idemKey}`);
|
|
@@ -640,6 +695,10 @@ var HttpAdapterError = class extends Error {
|
|
|
640
695
|
this.body = body;
|
|
641
696
|
this.name = "HttpAdapterError";
|
|
642
697
|
}
|
|
698
|
+
status;
|
|
699
|
+
method;
|
|
700
|
+
path;
|
|
701
|
+
body;
|
|
643
702
|
code = "http_adapter_error";
|
|
644
703
|
};
|
|
645
704
|
var CapabilitiesNotLoadedError = class extends Error {
|
|
@@ -1191,6 +1250,14 @@ function runAdapterConformance(makeAdapter, runner) {
|
|
|
1191
1250
|
expect(still.lane).toBe("generate");
|
|
1192
1251
|
});
|
|
1193
1252
|
const asConductor = (adapter) => adapter.listDueCards && adapter.countsByLane && adapter.capabilities ? adapter : null;
|
|
1253
|
+
const supportsField = (adapter, field) => {
|
|
1254
|
+
const fields = adapter.capabilities().dueRuleFields;
|
|
1255
|
+
return fields.includes("*") || fields.includes(field);
|
|
1256
|
+
};
|
|
1257
|
+
const supportsPredicate = (adapter, ref) => {
|
|
1258
|
+
const preds = adapter.capabilities().dueRulePredicates;
|
|
1259
|
+
return preds.includes("*") || preds.includes(ref);
|
|
1260
|
+
};
|
|
1194
1261
|
it("listDueCards {kind:'always'} returns every card in the lane", async () => {
|
|
1195
1262
|
const base = await makeAdapter();
|
|
1196
1263
|
const adapter = asConductor(base);
|
|
@@ -1222,6 +1289,7 @@ function runAdapterConformance(makeAdapter, runner) {
|
|
|
1222
1289
|
const base = await makeAdapter();
|
|
1223
1290
|
const adapter = asConductor(base);
|
|
1224
1291
|
if (!adapter) return;
|
|
1292
|
+
if (!supportsField(adapter, "next_touch_at")) return;
|
|
1225
1293
|
const past = new Date(Date.now() - 6e4).toISOString();
|
|
1226
1294
|
const future = new Date(Date.now() + 6e4).toISOString();
|
|
1227
1295
|
await seedCard(adapter, "scheduled", "due", { next_touch_at: past });
|
|
@@ -1243,6 +1311,7 @@ function runAdapterConformance(makeAdapter, runner) {
|
|
|
1243
1311
|
const base = await makeAdapter();
|
|
1244
1312
|
const adapter = asConductor(base);
|
|
1245
1313
|
if (!adapter) return;
|
|
1314
|
+
if (!supportsPredicate(adapter, "status")) return;
|
|
1246
1315
|
await seedCard(adapter, "queued", "approved", { status: "approved" });
|
|
1247
1316
|
await seedCard(adapter, "queued", "draft", { status: "draft" });
|
|
1248
1317
|
const rule = {
|
|
@@ -1259,6 +1328,8 @@ function runAdapterConformance(makeAdapter, runner) {
|
|
|
1259
1328
|
const base = await makeAdapter();
|
|
1260
1329
|
const adapter = asConductor(base);
|
|
1261
1330
|
if (!adapter) return;
|
|
1331
|
+
if (!supportsField(adapter, "next_touch_at") || !supportsPredicate(adapter, "status"))
|
|
1332
|
+
return;
|
|
1262
1333
|
const past = new Date(Date.now() - 6e4).toISOString();
|
|
1263
1334
|
const future = new Date(Date.now() + 6e4).toISOString();
|
|
1264
1335
|
await seedCard(adapter, "queued", "send-me", {
|
|
@@ -1521,20 +1592,28 @@ function createEngine(options) {
|
|
|
1521
1592
|
if (!begin.created) {
|
|
1522
1593
|
return { dispatched: false, executionUuid: begin.run.executionUuid };
|
|
1523
1594
|
}
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1595
|
+
let result;
|
|
1596
|
+
try {
|
|
1597
|
+
result = await dispatchStageHandler(
|
|
1598
|
+
{
|
|
1599
|
+
cardId,
|
|
1600
|
+
boardConfigUuid: card.boardId,
|
|
1601
|
+
configVersion: cfg.version,
|
|
1602
|
+
stage,
|
|
1603
|
+
idemKey,
|
|
1604
|
+
input: cardContext(card, stage),
|
|
1605
|
+
orgId: options.orgId,
|
|
1606
|
+
callbackUrl
|
|
1607
|
+
},
|
|
1608
|
+
cadraClient,
|
|
1609
|
+
webhookOptions
|
|
1610
|
+
);
|
|
1611
|
+
} catch (err) {
|
|
1612
|
+
await adapter.failStageRun(idemKey, {
|
|
1613
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1614
|
+
});
|
|
1615
|
+
throw err;
|
|
1616
|
+
}
|
|
1538
1617
|
if (result.executionUuid && adapter.markStageRunDispatched) {
|
|
1539
1618
|
await adapter.markStageRunDispatched(idemKey, {
|
|
1540
1619
|
executionUuid: result.executionUuid
|
|
@@ -1603,21 +1682,30 @@ function createEngine(options) {
|
|
|
1603
1682
|
}));
|
|
1604
1683
|
batchMembers.set(batchKey, batchCards.map((c) => c.idemKey));
|
|
1605
1684
|
const lead = eligible[0].card;
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1685
|
+
let result;
|
|
1686
|
+
try {
|
|
1687
|
+
result = await dispatchStageHandler(
|
|
1688
|
+
{
|
|
1689
|
+
cardId: lead.id,
|
|
1690
|
+
boardConfigUuid: lead.boardId,
|
|
1691
|
+
configVersion: cfg.version,
|
|
1692
|
+
stage,
|
|
1693
|
+
idemKey: eligible[0].idemKey,
|
|
1694
|
+
input: batchContext(lead, stage, batchCards),
|
|
1695
|
+
orgId: options.orgId,
|
|
1696
|
+
callbackUrl,
|
|
1697
|
+
batch: { batchKey, cards: batchCards }
|
|
1698
|
+
},
|
|
1699
|
+
cadraClient,
|
|
1700
|
+
webhookOptions
|
|
1701
|
+
);
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1704
|
+
for (const { idemKey } of eligible) {
|
|
1705
|
+
await adapter.failStageRun(idemKey, { error: msg });
|
|
1706
|
+
}
|
|
1707
|
+
throw err;
|
|
1708
|
+
}
|
|
1621
1709
|
if (result.executionUuid && adapter.markStageRunDispatched) {
|
|
1622
1710
|
for (const { idemKey } of eligible) {
|
|
1623
1711
|
await adapter.markStageRunDispatched(idemKey, {
|