@cadraos/sdk 0.3.14-p46.3 → 0.3.14

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.
Files changed (52) hide show
  1. package/README.md +104 -53
  2. package/dist/ai-functions/react/index.cjs +176 -0
  3. package/dist/ai-functions/react/index.cjs.map +1 -0
  4. package/dist/ai-functions/react/index.d.cts +129 -0
  5. package/dist/ai-functions/react/index.d.ts +129 -0
  6. package/dist/ai-functions/react/index.js +153 -0
  7. package/dist/ai-functions/react/index.js.map +1 -0
  8. package/dist/ai-functions/server/index.cjs +296 -0
  9. package/dist/ai-functions/server/index.cjs.map +1 -0
  10. package/dist/ai-functions/server/index.d.cts +334 -0
  11. package/dist/ai-functions/server/index.d.ts +334 -0
  12. package/dist/ai-functions/server/index.js +284 -0
  13. package/dist/ai-functions/server/index.js.map +1 -0
  14. package/dist/board-host/index.cjs +217 -129
  15. package/dist/board-host/index.cjs.map +1 -1
  16. package/dist/board-host/index.d.cts +96 -1
  17. package/dist/board-host/index.d.ts +96 -1
  18. package/dist/board-host/index.js +218 -131
  19. package/dist/board-host/index.js.map +1 -1
  20. package/dist/board-host/server/index.d.cts +44 -0
  21. package/dist/board-host/server/index.d.ts +44 -0
  22. package/dist/chat/index.cjs +299 -17
  23. package/dist/chat/index.cjs.map +1 -1
  24. package/dist/chat/index.d.cts +225 -0
  25. package/dist/chat/index.d.ts +225 -0
  26. package/dist/chat/index.js +299 -17
  27. package/dist/chat/index.js.map +1 -1
  28. package/dist/index.cjs +221 -3
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +286 -1
  31. package/dist/index.d.ts +286 -1
  32. package/dist/index.js +215 -4
  33. package/dist/index.js.map +1 -1
  34. package/dist/kanban/engine/index.cjs +774 -683
  35. package/dist/kanban/engine/index.cjs.map +1 -1
  36. package/dist/kanban/engine/index.d.cts +90 -1
  37. package/dist/kanban/engine/index.d.ts +90 -1
  38. package/dist/kanban/engine/index.js +774 -684
  39. package/dist/kanban/engine/index.js.map +1 -1
  40. package/dist/kanban/index.cjs +214 -3
  41. package/dist/kanban/index.cjs.map +1 -1
  42. package/dist/kanban/index.d.cts +252 -0
  43. package/dist/kanban/index.d.ts +252 -0
  44. package/dist/kanban/index.js +214 -3
  45. package/dist/kanban/index.js.map +1 -1
  46. package/dist/teams/index.cjs +214 -3
  47. package/dist/teams/index.cjs.map +1 -1
  48. package/dist/teams/index.d.cts +225 -0
  49. package/dist/teams/index.d.ts +225 -0
  50. package/dist/teams/index.js +214 -3
  51. package/dist/teams/index.js.map +1 -1
  52. package/package.json +21 -1
@@ -232,381 +232,6 @@ function parseHandoffVerdict(output) {
232
232
  }
233
233
  }
234
234
 
235
- // src/kanban/config-client.ts
236
- function isVersionRow(d) {
237
- return !!d && typeof d === "object" && "definition" in d && !!d.definition;
238
- }
239
- function mapHandler(h) {
240
- if (!h || !h.type || h.type === "none") {
241
- return void 0;
242
- }
243
- return {
244
- type: h.type,
245
- agentUuid: h.agent_uuid,
246
- teamUuid: h.team_uuid,
247
- // FINDING-6: carry the remote_agent binding through the wire parse. The
248
- // engine's remote_agent dispatch reads `remoteAgentUuid`; dropping it here
249
- // made every published remote_agent lane dispatch-dead.
250
- remoteAgentUuid: h.remote_agent_uuid,
251
- agentRef: h.agent_ref,
252
- webhook: h.webhook ? {
253
- url: h.webhook.url,
254
- headers: h.webhook.headers,
255
- timeoutMs: h.webhook.timeout_ms,
256
- retry: h.webhook.retry
257
- } : void 0
258
- };
259
- }
260
- function mapStage(s, index) {
261
- const lane = {
262
- // Carry the IMMUTABLE stage id through (p33 §10.2). It is what the
263
- // webhook-secret fetch + the stage-run idemKey + the UI columns key off —
264
- // a `key`/`label` rename never breaks dispatch or keying. `key` stays the
265
- // editable display/slug.
266
- id: s.id,
267
- key: s.key,
268
- label: s.name,
269
- order: index,
270
- handler: mapHandler(s.handler),
271
- terminal: s.is_terminal ?? false,
272
- initial: s.is_initial ?? false
273
- };
274
- if (s.trigger) lane.trigger = s.trigger;
275
- if (s.due_rule != null) lane.dueRule = s.due_rule;
276
- if (s.cap != null) lane.cap = s.cap;
277
- if (s.dispatch) lane.dispatch = s.dispatch;
278
- if (s.chunk_size != null) lane.chunkSize = s.chunk_size;
279
- if (s.handoff === "off" || s.handoff === "verdict") lane.handoff = s.handoff;
280
- return lane;
281
- }
282
- function mapTransition(t, idToKey) {
283
- return {
284
- id: t.id,
285
- fromLane: idToKey.get(t.from_stage_id) ?? t.from_stage_id,
286
- toLane: idToKey.get(t.to_stage_id) ?? t.to_stage_id,
287
- manualAllowed: t.manual_allowed ?? false,
288
- autoOnComplete: t.auto_on_complete ?? false,
289
- kind: t.kind
290
- };
291
- }
292
- function mapDefinitionToBoard(config, definition, version) {
293
- const lanes = (definition.stages ?? []).map(mapStage);
294
- const idToKey = new Map(
295
- (definition.stages ?? []).map((s) => [s.id, s.key])
296
- );
297
- const transitions = (definition.transitions ?? []).map(
298
- (t) => mapTransition(t, idToKey)
299
- );
300
- const board = {
301
- id: config.uuid,
302
- orgId: config.orgId ?? 0,
303
- lifecycleId: config.uuid,
304
- lifecycleVersion: version,
305
- name: config.name,
306
- lanes,
307
- transitions
308
- };
309
- return {
310
- uuid: config.uuid,
311
- version,
312
- name: config.name,
313
- slug: config.slug,
314
- board,
315
- definition: { stages: lanes, transitions }
316
- };
317
- }
318
- var DEFAULT_BASE_PATH = "/api/v1/board-configs";
319
- var BoardConfigClient = class {
320
- http;
321
- basePath;
322
- /** Cache keyed by `${uuid}:${version}`. */
323
- cache = /* @__PURE__ */ new Map();
324
- /** Last-known active version per uuid (for degraded active fetch). */
325
- activeVersions = /* @__PURE__ */ new Map();
326
- constructor(options) {
327
- this.http = options.http;
328
- this.basePath = options.basePath ?? DEFAULT_BASE_PATH;
329
- }
330
- /**
331
- * Fetch a config's ACTIVE version and map it. Re-validates against Cadra each
332
- * call (a newly published version supersedes the old active one). On a fetch
333
- * failure, serves the last cached copy for that config (graceful degradation,
334
- * design.md §11 R-cache); if nothing is cached, the error propagates.
335
- */
336
- async getActive(uuid) {
337
- try {
338
- const res = await this.http.get(
339
- `${this.basePath}/${uuid}`
340
- );
341
- const resolved = this.parse(res);
342
- this.put(resolved);
343
- this.activeVersions.set(uuid, resolved.version);
344
- return resolved;
345
- } catch (err) {
346
- const cached = this.cachedActive(uuid);
347
- if (cached) return cached;
348
- throw err;
349
- }
350
- }
351
- /**
352
- * Fetch a SPECIFIC frozen version and map it. Published versions are
353
- * immutable, so a cache hit skips the network. On a fetch miss + failure,
354
- * serves the cached version if present.
355
- */
356
- async getPinned(uuid, version) {
357
- const cached = this.cache.get(this.key(uuid, version));
358
- if (cached) return cached;
359
- try {
360
- const res = await this.http.get(
361
- `${this.basePath}/${uuid}`,
362
- { version }
363
- );
364
- const resolved = this.parse(res, version);
365
- this.put(resolved);
366
- return resolved;
367
- } catch (err) {
368
- const fallback = this.cache.get(this.key(uuid, version));
369
- if (fallback) return fallback;
370
- throw err;
371
- }
372
- }
373
- /** Drop all cached configs (e.g. after a known publish). */
374
- clearCache() {
375
- this.cache.clear();
376
- this.activeVersions.clear();
377
- }
378
- // -------------------------------------------------------------------------
379
- // internals
380
- // -------------------------------------------------------------------------
381
- key(uuid, version) {
382
- return `${uuid}:${version}`;
383
- }
384
- put(resolved) {
385
- this.cache.set(this.key(resolved.uuid, resolved.version), resolved);
386
- }
387
- cachedActive(uuid) {
388
- const v = this.activeVersions.get(uuid);
389
- if (v === void 0) return void 0;
390
- return this.cache.get(this.key(uuid, v));
391
- }
392
- /**
393
- * Normalize the wire envelope (active `{ config, definition, version }` OR
394
- * pinned `{ config, version: { version, definition } }`) into a resolved
395
- * config.
396
- */
397
- parse(res, requestedVersion) {
398
- if (!res?.config?.uuid) {
399
- throw new Error("Invalid board-config response: missing config.uuid");
400
- }
401
- let definition;
402
- let version;
403
- if (res.version && typeof res.version === "object") {
404
- definition = res.version.definition;
405
- version = res.version.version;
406
- } else if (isVersionRow(res.definition)) {
407
- definition = res.definition.definition;
408
- version = res.definition.version ?? res.config.activeVersion ?? void 0;
409
- } else {
410
- definition = res.definition;
411
- version = typeof res.version === "number" ? res.version : res.config.activeVersion ?? void 0;
412
- }
413
- if (!definition) {
414
- throw new Error("Invalid board-config response: missing definition");
415
- }
416
- const resolvedVersion = version ?? requestedVersion ?? 1;
417
- return mapDefinitionToBoard(res.config, definition, resolvedVersion);
418
- }
419
- };
420
- function toResolvedLifecycle(cfg) {
421
- return {
422
- uuid: cfg.uuid,
423
- version: cfg.version,
424
- source: "cadra",
425
- name: cfg.name,
426
- slug: cfg.slug,
427
- stages: cfg.board.lanes,
428
- transitions: cfg.board.transitions ?? [],
429
- board: cfg.board
430
- };
431
- }
432
- var CadraLifecycleSource = class {
433
- client;
434
- constructor(arg) {
435
- this.client = arg instanceof BoardConfigClient ? arg : new BoardConfigClient(arg);
436
- }
437
- /** The ACTIVE version, projected to a `ResolvedLifecycle` (`source: cadra`). */
438
- async getActive(uuid) {
439
- return toResolvedLifecycle(await this.client.getActive(uuid));
440
- }
441
- /** A PINNED version, projected to a `ResolvedLifecycle` (`source: cadra`). */
442
- async getPinned(uuid, version) {
443
- return toResolvedLifecycle(await this.client.getPinned(uuid, version));
444
- }
445
- /** Drop the underlying cache (e.g. after a known publish). */
446
- clearCache() {
447
- this.client.clearCache();
448
- }
449
- };
450
-
451
- // src/kanban/engine/handlers.ts
452
- var WebhookDeliveryError = class extends Error {
453
- constructor(message, status, retryable) {
454
- super(message);
455
- this.status = status;
456
- this.retryable = retryable;
457
- this.name = "WebhookDeliveryError";
458
- }
459
- status;
460
- retryable;
461
- };
462
- async function hmacSign(secret, payload) {
463
- const enc = new TextEncoder();
464
- const key = await crypto.subtle.importKey(
465
- "raw",
466
- enc.encode(secret),
467
- { name: "HMAC", hash: "SHA-256" },
468
- false,
469
- ["sign"]
470
- );
471
- const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload));
472
- return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
473
- }
474
- var sleepDefault = (ms) => new Promise((r) => setTimeout(r, ms));
475
- async function deliverWebhook(ctx, cadra, opts = {}) {
476
- const binding = ctx.stage.handler?.webhook;
477
- if (!binding) throw new Error(`Stage ${ctx.stage.key} has no webhook binding`);
478
- const doFetch = opts.fetchImpl ?? fetch;
479
- const maxAttempts = opts.maxAttempts ?? binding.retry ?? 3;
480
- const baseBackoff = opts.baseBackoffMs ?? 200;
481
- const sleep = opts.sleep ?? sleepDefault;
482
- const now = opts.now ?? Date.now;
483
- const stageId = ctx.stage.id ?? ctx.stage.key;
484
- const secretMaterial = await cadra.getWebhookSecret({
485
- boardConfigUuid: ctx.boardConfigUuid,
486
- stageId
487
- });
488
- const timestamp = now();
489
- const body = {
490
- idemKey: ctx.idemKey,
491
- cardId: ctx.cardId,
492
- boardConfigUuid: ctx.boardConfigUuid,
493
- configVersion: ctx.configVersion,
494
- stageId,
495
- input: ctx.input,
496
- callbackUrl: ctx.callbackUrl,
497
- callbackToken: secretMaterial?.callbackToken,
498
- timestamp
499
- };
500
- const payload = JSON.stringify(body);
501
- const signature = secretMaterial?.secret ? await hmacSign(secretMaterial.secret, `${timestamp}.${payload}`) : void 0;
502
- const headers = {
503
- "Content-Type": "application/json",
504
- "Idempotency-Key": ctx.idemKey,
505
- ...binding.headers ?? {}
506
- };
507
- if (signature) {
508
- headers["X-Cadra-Signature"] = `t=${timestamp},v1=${signature}`;
509
- }
510
- let attempt = 0;
511
- while (true) {
512
- attempt += 1;
513
- try {
514
- const res = await doFetch(binding.url, {
515
- method: "POST",
516
- headers,
517
- body: payload,
518
- ...binding.timeoutMs ? { signal: AbortSignal.timeout(binding.timeoutMs) } : {}
519
- });
520
- if (res.ok) return;
521
- if (res.status >= 400 && res.status < 500) {
522
- throw new WebhookDeliveryError(
523
- `Webhook ${binding.url} returned ${res.status}`,
524
- res.status,
525
- false
526
- );
527
- }
528
- throw new WebhookDeliveryError(
529
- `Webhook ${binding.url} returned ${res.status}`,
530
- res.status,
531
- true
532
- );
533
- } catch (err) {
534
- const retryable = err instanceof WebhookDeliveryError ? err.retryable : true;
535
- if (!retryable || attempt >= maxAttempts) {
536
- if (err instanceof WebhookDeliveryError) throw err;
537
- throw new WebhookDeliveryError(
538
- err instanceof Error ? err.message : String(err),
539
- void 0,
540
- true
541
- );
542
- }
543
- await sleep(baseBackoff * 2 ** (attempt - 1));
544
- }
545
- }
546
- }
547
- async function dispatchStageHandler(ctx, cadra, webhookOpts) {
548
- const handler = ctx.stage.handler;
549
- if (!handler || handler.type === "none" || handler.type === "human") {
550
- return { dispatched: false };
551
- }
552
- const runIdemKey = ctx.batch?.batchKey ?? ctx.idemKey;
553
- switch (handler.type) {
554
- case "cadra_agent": {
555
- if (!handler.agentUuid) {
556
- throw new Error(`Stage ${ctx.stage.key} (cadra_agent) missing agentUuid`);
557
- }
558
- const { executionUuid } = await cadra.runAgent({
559
- agentUuid: handler.agentUuid,
560
- orgId: ctx.orgId,
561
- input: ctx.input,
562
- idemKey: runIdemKey,
563
- // Board/headless dispatch always disables ask_user (spec §6.5 / FINDING-5).
564
- allowAskUser: false,
565
- callbackRef: ctx.callbackUrl
566
- });
567
- return { dispatched: true, executionUuid };
568
- }
569
- case "cadra_team": {
570
- if (!handler.teamUuid) {
571
- throw new Error(`Stage ${ctx.stage.key} (cadra_team) missing teamUuid`);
572
- }
573
- const { executionUuid } = await cadra.runTeam({
574
- teamUuid: handler.teamUuid,
575
- orgId: ctx.orgId,
576
- input: ctx.input,
577
- idemKey: runIdemKey,
578
- // Board/headless dispatch always disables ask_user (spec §6.5 / FINDING-5).
579
- allowAskUser: false,
580
- callbackRef: ctx.callbackUrl
581
- });
582
- return { dispatched: true, executionUuid };
583
- }
584
- case "webhook": {
585
- await deliverWebhook(ctx, cadra, webhookOpts);
586
- return { dispatched: true };
587
- }
588
- case "remote_agent": {
589
- const h = handler;
590
- const remoteAgentUuid = h["remoteAgentUuid"] ?? h["remote_agent_uuid"];
591
- if (!remoteAgentUuid) {
592
- throw new Error(`Stage ${ctx.stage.key} (remote_agent) missing remoteAgentUuid`);
593
- }
594
- const { executionUuid } = await cadra.runRemoteAgent({
595
- remoteAgentUuid,
596
- orgId: ctx.orgId,
597
- input: ctx.input,
598
- idemKey: runIdemKey,
599
- // Board/headless dispatch always disables ask_user (spec §6.5).
600
- allowAskUser: false,
601
- callbackRef: ctx.callbackUrl
602
- });
603
- return { dispatched: true, executionUuid };
604
- }
605
- default:
606
- return { dispatched: false };
607
- }
608
- }
609
-
610
235
  // src/kanban/adapters/in-memory-adapter.ts
611
236
  var TransitionConflictError = class extends Error {
612
237
  code = "transition_conflict";
@@ -615,6 +240,13 @@ var TransitionConflictError = class extends Error {
615
240
  this.name = "TransitionConflictError";
616
241
  }
617
242
  };
243
+ var HostFenceError = class extends Error {
244
+ code = "host_fence";
245
+ constructor(message = "Card has a pending host op; host move skipped") {
246
+ super(message);
247
+ this.name = "HostFenceError";
248
+ }
249
+ };
618
250
  var UnknownDueRuleError = class extends Error {
619
251
  code = "unknown_due_rule_kind";
620
252
  constructor(kind) {
@@ -632,6 +264,8 @@ var InMemoryStorageAdapter = class {
632
264
  cards = /* @__PURE__ */ new Map();
633
265
  logs = /* @__PURE__ */ new Map();
634
266
  runs = /* @__PURE__ */ new Map();
267
+ /** Lane KEY the card was in when each run was (re)claimed (idemKey → lane). */
268
+ runLanes = /* @__PURE__ */ new Map();
635
269
  // p46 Handoff ledger + op outbox (the full optional group).
636
270
  handoffs = [];
637
271
  ops = /* @__PURE__ */ new Map();
@@ -665,7 +299,19 @@ var InMemoryStorageAdapter = class {
665
299
  const c = this.cards.get(cardId);
666
300
  return c ? this.publicCard(c) : null;
667
301
  }
302
+ async getCardByExternalRef(boardConfigUuid, externalRef) {
303
+ for (const c of this.cards.values()) {
304
+ if (c.boardConfigUuid === boardConfigUuid && c.externalRef === externalRef) {
305
+ return this.publicCard(c);
306
+ }
307
+ }
308
+ return null;
309
+ }
668
310
  async createCard(input) {
311
+ if (input.externalRef !== void 0) {
312
+ const existing = await this.getCardByExternalRef(input.boardConfigUuid, input.externalRef);
313
+ if (existing) return existing;
314
+ }
669
315
  const id = nextId("card");
670
316
  const now = (/* @__PURE__ */ new Date()).toISOString();
671
317
  const card = {
@@ -683,6 +329,7 @@ var InMemoryStorageAdapter = class {
683
329
  description: input.description,
684
330
  type: input.type,
685
331
  sharedContext: input.sharedContext,
332
+ ...input.externalRef !== void 0 ? { externalRef: input.externalRef } : {},
686
333
  createdAt: now,
687
334
  updatedAt: now
688
335
  };
@@ -707,6 +354,18 @@ var InMemoryStorageAdapter = class {
707
354
  `Expected lane ${move.fromLane}, card is in ${c.lane}`
708
355
  );
709
356
  }
357
+ if (move.hostObservation) {
358
+ const observedAt = move.hostObservation.observedAt;
359
+ for (const op of this.ops.values()) {
360
+ if (op.cardId !== cardId || !op.kind.startsWith("host.")) continue;
361
+ const settledAfter = observedAt !== void 0 && op.settledAt !== void 0 && Date.parse(op.settledAt) > Date.parse(observedAt);
362
+ if (op.status === "pending" || settledAfter) {
363
+ throw new HostFenceError(
364
+ `Card ${cardId} has ${op.status === "pending" ? "pending" : "freshly settled"} ${op.kind} ${op.opId}`
365
+ );
366
+ }
367
+ }
368
+ }
710
369
  c.lane = move.toLane;
711
370
  if (move.contextPatch) {
712
371
  c.sharedContext = { ...c.sharedContext ?? {}, ...move.contextPatch };
@@ -744,353 +403,740 @@ var InMemoryStorageAdapter = class {
744
403
  if (c.boardConfigUuid !== boardConfigUuid) continue;
745
404
  counts[c.lane] = (counts[c.lane] ?? 0) + 1;
746
405
  }
747
- return counts;
406
+ return counts;
407
+ }
408
+ capabilities() {
409
+ return {
410
+ dueRuleFields: ["*"],
411
+ dueRulePredicates: ["*"],
412
+ membership: "adapter-resolved"
413
+ };
414
+ }
415
+ // ----- Stage runs -----
416
+ async beginStageRun(cardId, r) {
417
+ const existing = this.runs.get(r.idemKey);
418
+ if (existing) {
419
+ if (this.isReclaimable(existing)) {
420
+ const run2 = {
421
+ idemKey: r.idemKey,
422
+ cardId,
423
+ stageId: r.stageId,
424
+ handlerType: r.handlerType,
425
+ status: "queued",
426
+ attempts: (existing.attempts ?? 1) + 1,
427
+ createdAt: new Date(this.now()).toISOString()
428
+ };
429
+ this.runs.set(r.idemKey, run2);
430
+ this.recordRunLane(cardId, r.idemKey);
431
+ return { created: true, run: { ...run2 } };
432
+ }
433
+ return { created: false, run: { ...existing } };
434
+ }
435
+ const run = {
436
+ idemKey: r.idemKey,
437
+ cardId,
438
+ stageId: r.stageId,
439
+ handlerType: r.handlerType,
440
+ status: "queued",
441
+ attempts: 1,
442
+ createdAt: new Date(this.now()).toISOString()
443
+ };
444
+ this.runs.set(r.idemKey, run);
445
+ this.recordRunLane(cardId, r.idemKey);
446
+ return { created: true, run: { ...run } };
447
+ }
448
+ recordRunLane(cardId, idemKey) {
449
+ const lane = this.cards.get(cardId)?.lane;
450
+ if (lane !== void 0) this.runLanes.set(idemKey, lane);
451
+ }
452
+ /**
453
+ * A stranded stage-run the next `beginStageRun` may re-claim (design §10): a
454
+ * `failed` run (dispatch threw), or a `queued` run older than `staleQueuedMs`
455
+ * (a process died before dispatch flipped it). `running`/`completed` runs are
456
+ * never re-claimed.
457
+ */
458
+ isReclaimable(run) {
459
+ if (run.status === "failed") return true;
460
+ if (run.status === "queued") {
461
+ const age = this.now() - Date.parse(run.createdAt);
462
+ return Number.isFinite(age) && age >= this.staleQueuedMs;
463
+ }
464
+ return false;
465
+ }
466
+ async completeStageRun(idemKey, c) {
467
+ const run = this.runs.get(idemKey);
468
+ if (!run) throw new Error(`No stage run for idemKey ${idemKey}`);
469
+ if (run.status === "completed") return { applied: false };
470
+ return this.transaction(async () => {
471
+ run.status = "completed";
472
+ if (c.executionUuid) run.executionUuid = c.executionUuid;
473
+ if (c.toLane) {
474
+ const card = this.requireCard(run.cardId);
475
+ const runLane = this.runLanes.get(idemKey);
476
+ const leftRunLane = runLane !== void 0 && card.lane !== runLane;
477
+ if (card.lane !== c.toLane && !leftRunLane) {
478
+ await this.applyTransition(run.cardId, {
479
+ fromLane: card.lane,
480
+ toLane: c.toLane,
481
+ actor: c.actor,
482
+ contextPatch: c.contextPatch,
483
+ executionUuid: c.executionUuid ?? run.executionUuid
484
+ });
485
+ }
486
+ }
487
+ this.writeLedger(run, c);
488
+ this.txFault?.("completeStageRun");
489
+ return { applied: true };
490
+ });
491
+ }
492
+ async failStageRun(idemKey, f) {
493
+ const run = this.runs.get(idemKey);
494
+ if (!run) throw new Error(`No stage run for idemKey ${idemKey}`);
495
+ if (run.status === "completed") return;
496
+ await this.transaction(async () => {
497
+ run.status = "failed";
498
+ run.error = f.error;
499
+ this.writeLedger(run, f);
500
+ this.txFault?.("failStageRun");
501
+ });
502
+ }
503
+ async getStageRun(idemKey) {
504
+ const r = this.runs.get(idemKey);
505
+ return r ? { ...r } : null;
506
+ }
507
+ async markStageRunDispatched(idemKey, d) {
508
+ const run = this.runs.get(idemKey);
509
+ if (!run) return;
510
+ if (run.status === "queued") run.status = "running";
511
+ if (d.executionUuid) {
512
+ run.executionUuid = d.executionUuid;
513
+ const card = this.cards.get(run.cardId);
514
+ if (card) card.executionId = d.executionUuid;
515
+ }
516
+ }
517
+ // ----- Handoff ledger + op outbox (p46 §4) -----
518
+ async appendHandoff(h) {
519
+ const existing = this.findConflict(h.card.id, h.stageId, h.configVersion, h.payload.kind, h.dedupeKey);
520
+ if (existing) return { created: false, handoff: this.toV1(existing.record) };
521
+ const row = this.insertHandoff({
522
+ payload: { ...h.payload, id: nextId("handoff") },
523
+ stageId: h.stageId,
524
+ configVersion: h.configVersion,
525
+ idemKey: h.idemKey,
526
+ attempt: h.attempt,
527
+ dedupeKey: h.dedupeKey
528
+ });
529
+ return { created: true, handoff: this.toV1(row.record) };
530
+ }
531
+ async listHandoffs(cardId, opts) {
532
+ const rows = this.handoffs.filter((r) => r.record.cardId === cardId).sort((a, b) => b.seq - a.seq).map((r) => structuredClone(r.record));
533
+ return opts?.limit !== void 0 ? rows.slice(0, opts.limit) : rows;
534
+ }
535
+ async answerHandoff(cardId, handoffId, answer, userId) {
536
+ const row = this.handoffs.find((r) => r.record.id === handoffId);
537
+ if (!row || row.record.cardId !== cardId || row.record.direction !== "ai_to_human" || !ANSWERABLE_HANDOFF_KINDS.includes(row.record.kind) || row.record.status !== "open") {
538
+ return { applied: false };
539
+ }
540
+ row.record.status = "answered";
541
+ row.record.answer = { ...answer };
542
+ row.answeredBy = userId;
543
+ return { applied: true };
544
+ }
545
+ async resolveHandoff(handoffId) {
546
+ const row = this.handoffs.find((r) => r.record.id === handoffId);
547
+ if (!row || row.record.status !== "open") return { applied: false };
548
+ row.record.status = "resolved";
549
+ return { applied: true };
550
+ }
551
+ async consumeHandoffs(ids, idemKey) {
552
+ const at = new Date(this.now()).toISOString();
553
+ for (const row of this.handoffs) {
554
+ if (ids.includes(row.record.id) && row.record.direction === "human_to_ai" && row.record.status === "open") {
555
+ row.record.status = "consumed";
556
+ row.record.consumedAt = at;
557
+ row.consumedByIdemKey = idemKey;
558
+ }
559
+ }
560
+ }
561
+ async claimOp(op) {
562
+ const existing = this.ops.get(op.opId);
563
+ if (existing) {
564
+ if (existing.status === "done") return { run: false };
565
+ existing.attempts += 1;
566
+ return { run: true };
567
+ }
568
+ this.ops.set(op.opId, { ...op, status: "pending", attempts: 1 });
569
+ return { run: true };
570
+ }
571
+ async completeOp(opId) {
572
+ const op = this.ops.get(opId);
573
+ if (op) {
574
+ op.status = "done";
575
+ op.settledAt = new Date(this.now()).toISOString();
576
+ }
577
+ }
578
+ async failOp(opId, error) {
579
+ const op = this.ops.get(opId);
580
+ if (op) {
581
+ op.status = "failed";
582
+ op.lastError = error;
583
+ op.settledAt = new Date(this.now()).toISOString();
584
+ }
585
+ }
586
+ /** Test/inspection helper: read one outbox op. */
587
+ getOp(opId) {
588
+ const op = this.ops.get(opId);
589
+ return op ? structuredClone(op) : void 0;
590
+ }
591
+ // ----- internals -----
592
+ /**
593
+ * Run `fn` as one all-or-nothing "transaction": snapshot every table, and
594
+ * restore the snapshot if `fn` throws (models the host's SQL tx rollback).
595
+ */
596
+ async transaction(fn) {
597
+ const snap = {
598
+ cards: structuredClone(this.cards),
599
+ logs: structuredClone(this.logs),
600
+ runs: structuredClone(this.runs),
601
+ handoffs: structuredClone(this.handoffs),
602
+ ops: structuredClone(this.ops),
603
+ handoffSeq: this.handoffSeq
604
+ };
605
+ try {
606
+ return await fn();
607
+ } catch (err) {
608
+ this.cards = snap.cards;
609
+ this.logs = snap.logs;
610
+ this.runs = snap.runs;
611
+ this.handoffs = snap.handoffs;
612
+ this.ops = snap.ops;
613
+ this.handoffSeq = snap.handoffSeq;
614
+ throw err;
615
+ }
616
+ }
617
+ /** The F3 in-tx ledger write for completeStageRun / failStageRun. */
618
+ writeLedger(run, w) {
619
+ if (!w.handoff) return;
620
+ const p = w.handoff.payload;
621
+ const conflict = this.findConflict(p.cardId, run.stageId, this.versionOf(run.idemKey), p.kind, w.handoff.dedupeKey);
622
+ if (conflict) return;
623
+ const row = this.insertHandoff({
624
+ payload: p,
625
+ stageId: run.stageId,
626
+ configVersion: this.versionOf(run.idemKey),
627
+ idemKey: run.idemKey,
628
+ attempt: run.attempts ?? 1,
629
+ dedupeKey: w.handoff.dedupeKey
630
+ });
631
+ if (w.notifyOpId && !this.ops.has(w.notifyOpId)) {
632
+ this.ops.set(w.notifyOpId, {
633
+ opId: w.notifyOpId,
634
+ kind: "notify.in_app",
635
+ cardId: p.cardId,
636
+ handoffId: row.record.id,
637
+ payload: { handoffId: row.record.id },
638
+ status: "pending",
639
+ attempts: 0
640
+ });
641
+ }
642
+ }
643
+ findConflict(cardId, stageId, configVersion, kind, dedupeKey) {
644
+ if (dedupeKey) {
645
+ const d = this.handoffs.find((r) => r.dedupeKey === dedupeKey);
646
+ if (d) return d;
647
+ }
648
+ if (kind === "BLOCKED") {
649
+ return this.handoffs.find(
650
+ (r) => r.record.kind === "BLOCKED" && r.record.status === "open" && r.record.cardId === cardId && r.stageId === stageId && r.configVersion === configVersion
651
+ );
652
+ }
653
+ return void 0;
748
654
  }
749
- capabilities() {
750
- return {
751
- dueRuleFields: ["*"],
752
- dueRulePredicates: ["*"],
753
- membership: "adapter-resolved"
655
+ insertHandoff(r) {
656
+ this.handoffSeq += 1;
657
+ const row = {
658
+ record: { ...structuredClone(r.payload), status: "open", consumedAt: null },
659
+ stageId: r.stageId,
660
+ configVersion: r.configVersion,
661
+ idemKey: r.idemKey,
662
+ attempt: r.attempt,
663
+ dedupeKey: r.dedupeKey,
664
+ seq: this.handoffSeq
754
665
  };
666
+ this.handoffs.push(row);
667
+ return row;
755
668
  }
756
- // ----- Stage runs -----
757
- async beginStageRun(cardId, r) {
758
- const existing = this.runs.get(r.idemKey);
759
- if (existing) {
760
- if (this.isReclaimable(existing)) {
761
- const run2 = {
762
- idemKey: r.idemKey,
763
- cardId,
764
- stageId: r.stageId,
765
- handlerType: r.handlerType,
766
- status: "queued",
767
- attempts: (existing.attempts ?? 1) + 1,
768
- createdAt: new Date(this.now()).toISOString()
769
- };
770
- this.runs.set(r.idemKey, run2);
771
- return { created: true, run: { ...run2 } };
669
+ toV1(rec) {
670
+ const { status: _s, consumedAt: _c, ...v1 } = structuredClone(rec);
671
+ return v1;
672
+ }
673
+ /** The trailing config version of an idemKey (`cardId:stageId:version`). */
674
+ versionOf(idemKey) {
675
+ return Number(idemKey.slice(idemKey.lastIndexOf(":") + 1));
676
+ }
677
+ /**
678
+ * Reject (throw) if any node in the rule tree is not a known `kind` — checked
679
+ * up front so a bad rule fails closed before we ever inspect a card.
680
+ */
681
+ assertKnownKind(rule) {
682
+ switch (rule.kind) {
683
+ case "always":
684
+ case "backlog":
685
+ case "field_due":
686
+ case "predicate":
687
+ return;
688
+ case "and":
689
+ for (const r of rule.rules) this.assertKnownKind(r);
690
+ return;
691
+ default:
692
+ throw new UnknownDueRuleError(
693
+ rule.kind ?? "<missing>"
694
+ );
695
+ }
696
+ }
697
+ /**
698
+ * Evaluate a (validated) `DueRule` against an in-memory card. `field_due` /
699
+ * `predicate` read the card's `sharedContext` (the reference adapter's only
700
+ * "host data"); a real host pushes these down to SQL over its own columns.
701
+ */
702
+ isDue(card, rule, now) {
703
+ switch (rule.kind) {
704
+ case "always":
705
+ return true;
706
+ case "backlog":
707
+ return true;
708
+ case "field_due": {
709
+ const v = card.sharedContext?.[rule.field];
710
+ if (v == null) return false;
711
+ const left = this.toComparable(v);
712
+ const right = rule.value === "now" ? now : this.toComparable(rule.value);
713
+ if (left == null || right == null) return false;
714
+ return left <= right;
772
715
  }
773
- return { created: false, run: { ...existing } };
716
+ case "predicate": {
717
+ const v = card.sharedContext?.[rule.ref];
718
+ if (v == null) return false;
719
+ return String(v) === String(rule.value);
720
+ }
721
+ case "and":
722
+ return rule.rules.every((r) => this.isDue(card, r, now));
723
+ default:
724
+ throw new UnknownDueRuleError(
725
+ rule.kind ?? "<missing>"
726
+ );
774
727
  }
775
- const run = {
776
- idemKey: r.idemKey,
777
- cardId,
778
- stageId: r.stageId,
779
- handlerType: r.handlerType,
780
- status: "queued",
781
- attempts: 1,
782
- createdAt: new Date(this.now()).toISOString()
783
- };
784
- this.runs.set(r.idemKey, run);
785
- return { created: true, run: { ...run } };
786
728
  }
787
729
  /**
788
- * A stranded stage-run the next `beginStageRun` may re-claim (design §10): a
789
- * `failed` run (dispatch threw), or a `queued` run older than `staleQueuedMs`
790
- * (a process died before dispatch flipped it). `running`/`completed` runs are
791
- * never re-claimed.
730
+ * Coerce a value to a comparable primitive for `field_due` (`<=`): dates →
731
+ * epoch ms, numeric strings → numbers, else the raw number/string.
792
732
  */
793
- isReclaimable(run) {
794
- if (run.status === "failed") return true;
795
- if (run.status === "queued") {
796
- const age = this.now() - Date.parse(run.createdAt);
797
- return Number.isFinite(age) && age >= this.staleQueuedMs;
733
+ toComparable(v) {
734
+ if (typeof v === "number") return v;
735
+ if (v instanceof Date) return v.getTime();
736
+ if (typeof v === "string") {
737
+ const t = Date.parse(v);
738
+ if (!Number.isNaN(t)) return t;
739
+ const n = Number(v);
740
+ if (!Number.isNaN(n) && v.trim() !== "") return n;
741
+ return v;
798
742
  }
799
- return false;
743
+ return null;
800
744
  }
801
- async completeStageRun(idemKey, c) {
802
- const run = this.runs.get(idemKey);
803
- if (!run) throw new Error(`No stage run for idemKey ${idemKey}`);
804
- if (run.status === "completed") return { applied: false };
805
- return this.transaction(async () => {
806
- run.status = "completed";
807
- if (c.executionUuid) run.executionUuid = c.executionUuid;
808
- if (c.toLane) {
809
- const card = this.requireCard(run.cardId);
810
- if (card.lane !== c.toLane) {
811
- await this.applyTransition(run.cardId, {
812
- fromLane: card.lane,
813
- toLane: c.toLane,
814
- actor: c.actor,
815
- contextPatch: c.contextPatch,
816
- executionUuid: c.executionUuid ?? run.executionUuid
817
- });
818
- }
819
- }
820
- this.writeLedger(run, c);
821
- this.txFault?.("completeStageRun");
822
- return { applied: true };
823
- });
745
+ requireCard(cardId) {
746
+ const c = this.cards.get(cardId);
747
+ if (!c) throw new Error(`Card not found: ${cardId}`);
748
+ return c;
824
749
  }
825
- async failStageRun(idemKey, f) {
826
- const run = this.runs.get(idemKey);
827
- if (!run) throw new Error(`No stage run for idemKey ${idemKey}`);
828
- if (run.status === "completed") return;
829
- await this.transaction(async () => {
830
- run.status = "failed";
831
- run.error = f.error;
832
- this.writeLedger(run, f);
833
- this.txFault?.("failStageRun");
750
+ appendLog(cardId, entry) {
751
+ const list = this.logs.get(cardId) ?? [];
752
+ list.push({
753
+ id: nextId("log"),
754
+ cardId,
755
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
756
+ ...entry
834
757
  });
758
+ this.logs.set(cardId, list);
835
759
  }
836
- async getStageRun(idemKey) {
837
- const r = this.runs.get(idemKey);
838
- return r ? { ...r } : null;
839
- }
840
- async markStageRunDispatched(idemKey, d) {
841
- const run = this.runs.get(idemKey);
842
- if (!run) return;
843
- if (run.status === "queued") run.status = "running";
844
- if (d.executionUuid) {
845
- run.executionUuid = d.executionUuid;
846
- const card = this.cards.get(run.cardId);
847
- if (card) card.executionId = d.executionUuid;
848
- }
760
+ publicCard(c) {
761
+ const { boardConfigUuid: _b, ...pub } = c;
762
+ return { ...pub };
849
763
  }
850
- // ----- Handoff ledger + op outbox (p46 §4) -----
851
- async appendHandoff(h) {
852
- const existing = this.findConflict(h.card.id, h.stageId, h.configVersion, h.payload.kind, h.dedupeKey);
853
- if (existing) return { created: false, handoff: this.toV1(existing.record) };
854
- const row = this.insertHandoff({
855
- payload: { ...h.payload, id: nextId("handoff") },
856
- stageId: h.stageId,
857
- configVersion: h.configVersion,
858
- idemKey: h.idemKey,
859
- attempt: h.attempt,
860
- dedupeKey: h.dedupeKey
861
- });
862
- return { created: true, handoff: this.toV1(row.record) };
764
+ };
765
+
766
+ // src/kanban/config-client.ts
767
+ function isVersionRow(d) {
768
+ return !!d && typeof d === "object" && "definition" in d && !!d.definition;
769
+ }
770
+ function mapHandler(h) {
771
+ if (!h || !h.type || h.type === "none") {
772
+ return void 0;
863
773
  }
864
- async listHandoffs(cardId, opts) {
865
- const rows = this.handoffs.filter((r) => r.record.cardId === cardId).sort((a, b) => b.seq - a.seq).map((r) => structuredClone(r.record));
866
- return opts?.limit !== void 0 ? rows.slice(0, opts.limit) : rows;
774
+ return {
775
+ type: h.type,
776
+ agentUuid: h.agent_uuid,
777
+ teamUuid: h.team_uuid,
778
+ // FINDING-6: carry the remote_agent binding through the wire parse. The
779
+ // engine's remote_agent dispatch reads `remoteAgentUuid`; dropping it here
780
+ // made every published remote_agent lane dispatch-dead.
781
+ remoteAgentUuid: h.remote_agent_uuid,
782
+ agentRef: h.agent_ref,
783
+ webhook: h.webhook ? {
784
+ url: h.webhook.url,
785
+ headers: h.webhook.headers,
786
+ timeoutMs: h.webhook.timeout_ms,
787
+ retry: h.webhook.retry
788
+ } : void 0
789
+ };
790
+ }
791
+ function mapStage(s, index) {
792
+ const lane = {
793
+ // Carry the IMMUTABLE stage id through (p33 §10.2). It is what the
794
+ // webhook-secret fetch + the stage-run idemKey + the UI columns key off —
795
+ // a `key`/`label` rename never breaks dispatch or keying. `key` stays the
796
+ // editable display/slug.
797
+ id: s.id,
798
+ key: s.key,
799
+ label: s.name,
800
+ order: index,
801
+ handler: mapHandler(s.handler),
802
+ terminal: s.is_terminal ?? false,
803
+ initial: s.is_initial ?? false
804
+ };
805
+ if (s.trigger) lane.trigger = s.trigger;
806
+ if (s.due_rule != null) lane.dueRule = s.due_rule;
807
+ if (s.cap != null) lane.cap = s.cap;
808
+ if (s.dispatch) lane.dispatch = s.dispatch;
809
+ if (s.chunk_size != null) lane.chunkSize = s.chunk_size;
810
+ if (s.handoff === "off" || s.handoff === "verdict") lane.handoff = s.handoff;
811
+ return lane;
812
+ }
813
+ function mapTransition(t, idToKey) {
814
+ return {
815
+ id: t.id,
816
+ fromLane: idToKey.get(t.from_stage_id) ?? t.from_stage_id,
817
+ toLane: idToKey.get(t.to_stage_id) ?? t.to_stage_id,
818
+ manualAllowed: t.manual_allowed ?? false,
819
+ autoOnComplete: t.auto_on_complete ?? false,
820
+ kind: t.kind
821
+ };
822
+ }
823
+ function mapDefinitionToBoard(config, definition, version) {
824
+ const lanes = (definition.stages ?? []).map(mapStage);
825
+ const idToKey = new Map(
826
+ (definition.stages ?? []).map((s) => [s.id, s.key])
827
+ );
828
+ const transitions = (definition.transitions ?? []).map(
829
+ (t) => mapTransition(t, idToKey)
830
+ );
831
+ const board = {
832
+ id: config.uuid,
833
+ orgId: config.orgId ?? 0,
834
+ lifecycleId: config.uuid,
835
+ lifecycleVersion: version,
836
+ name: config.name,
837
+ lanes,
838
+ transitions
839
+ };
840
+ return {
841
+ uuid: config.uuid,
842
+ version,
843
+ name: config.name,
844
+ slug: config.slug,
845
+ board,
846
+ definition: { stages: lanes, transitions }
847
+ };
848
+ }
849
+ var DEFAULT_BASE_PATH = "/api/v1/board-configs";
850
+ var BoardConfigClient = class {
851
+ http;
852
+ basePath;
853
+ /** Cache keyed by `${uuid}:${version}`. */
854
+ cache = /* @__PURE__ */ new Map();
855
+ /** Last-known active version per uuid (for degraded active fetch). */
856
+ activeVersions = /* @__PURE__ */ new Map();
857
+ constructor(options) {
858
+ this.http = options.http;
859
+ this.basePath = options.basePath ?? DEFAULT_BASE_PATH;
867
860
  }
868
- async answerHandoff(cardId, handoffId, answer, userId) {
869
- const row = this.handoffs.find((r) => r.record.id === handoffId);
870
- if (!row || row.record.cardId !== cardId || row.record.direction !== "ai_to_human" || !ANSWERABLE_HANDOFF_KINDS.includes(row.record.kind) || row.record.status !== "open") {
871
- return { applied: false };
861
+ /**
862
+ * Fetch a config's ACTIVE version and map it. Re-validates against Cadra each
863
+ * call (a newly published version supersedes the old active one). On a fetch
864
+ * failure, serves the last cached copy for that config (graceful degradation,
865
+ * design.md §11 R-cache); if nothing is cached, the error propagates.
866
+ */
867
+ async getActive(uuid) {
868
+ try {
869
+ const res = await this.http.get(
870
+ `${this.basePath}/${uuid}`
871
+ );
872
+ const resolved = this.parse(res);
873
+ this.put(resolved);
874
+ this.activeVersions.set(uuid, resolved.version);
875
+ return resolved;
876
+ } catch (err) {
877
+ const cached = this.cachedActive(uuid);
878
+ if (cached) return cached;
879
+ throw err;
872
880
  }
873
- row.record.status = "answered";
874
- row.record.answer = { ...answer };
875
- row.answeredBy = userId;
876
- return { applied: true };
877
- }
878
- async resolveHandoff(handoffId) {
879
- const row = this.handoffs.find((r) => r.record.id === handoffId);
880
- if (!row || row.record.status !== "open") return { applied: false };
881
- row.record.status = "resolved";
882
- return { applied: true };
883
881
  }
884
- async consumeHandoffs(ids, idemKey) {
885
- const at = new Date(this.now()).toISOString();
886
- for (const row of this.handoffs) {
887
- if (ids.includes(row.record.id) && row.record.direction === "human_to_ai" && row.record.status === "open") {
888
- row.record.status = "consumed";
889
- row.record.consumedAt = at;
890
- row.consumedByIdemKey = idemKey;
891
- }
882
+ /**
883
+ * Fetch a SPECIFIC frozen version and map it. Published versions are
884
+ * immutable, so a cache hit skips the network. On a fetch miss + failure,
885
+ * serves the cached version if present.
886
+ */
887
+ async getPinned(uuid, version) {
888
+ const cached = this.cache.get(this.key(uuid, version));
889
+ if (cached) return cached;
890
+ try {
891
+ const res = await this.http.get(
892
+ `${this.basePath}/${uuid}`,
893
+ { version }
894
+ );
895
+ const resolved = this.parse(res, version);
896
+ this.put(resolved);
897
+ return resolved;
898
+ } catch (err) {
899
+ const fallback = this.cache.get(this.key(uuid, version));
900
+ if (fallback) return fallback;
901
+ throw err;
892
902
  }
893
903
  }
894
- async claimOp(op) {
895
- const existing = this.ops.get(op.opId);
896
- if (existing) {
897
- if (existing.status === "done") return { run: false };
898
- existing.attempts += 1;
899
- return { run: true };
900
- }
901
- this.ops.set(op.opId, { ...op, status: "pending", attempts: 1 });
902
- return { run: true };
904
+ /** Drop all cached configs (e.g. after a known publish). */
905
+ clearCache() {
906
+ this.cache.clear();
907
+ this.activeVersions.clear();
903
908
  }
904
- async completeOp(opId) {
905
- const op = this.ops.get(opId);
906
- if (op) op.status = "done";
909
+ // -------------------------------------------------------------------------
910
+ // internals
911
+ // -------------------------------------------------------------------------
912
+ key(uuid, version) {
913
+ return `${uuid}:${version}`;
907
914
  }
908
- async failOp(opId, error) {
909
- const op = this.ops.get(opId);
910
- if (op) {
911
- op.status = "failed";
912
- op.lastError = error;
913
- }
915
+ put(resolved) {
916
+ this.cache.set(this.key(resolved.uuid, resolved.version), resolved);
914
917
  }
915
- /** Test/inspection helper: read one outbox op. */
916
- getOp(opId) {
917
- const op = this.ops.get(opId);
918
- return op ? structuredClone(op) : void 0;
918
+ cachedActive(uuid) {
919
+ const v = this.activeVersions.get(uuid);
920
+ if (v === void 0) return void 0;
921
+ return this.cache.get(this.key(uuid, v));
919
922
  }
920
- // ----- internals -----
921
923
  /**
922
- * Run `fn` as one all-or-nothing "transaction": snapshot every table, and
923
- * restore the snapshot if `fn` throws (models the host's SQL tx rollback).
924
+ * Normalize the wire envelope (active `{ config, definition, version }` OR
925
+ * pinned `{ config, version: { version, definition } }`) into a resolved
926
+ * config.
924
927
  */
925
- async transaction(fn) {
926
- const snap = {
927
- cards: structuredClone(this.cards),
928
- logs: structuredClone(this.logs),
929
- runs: structuredClone(this.runs),
930
- handoffs: structuredClone(this.handoffs),
931
- ops: structuredClone(this.ops),
932
- handoffSeq: this.handoffSeq
933
- };
934
- try {
935
- return await fn();
936
- } catch (err) {
937
- this.cards = snap.cards;
938
- this.logs = snap.logs;
939
- this.runs = snap.runs;
940
- this.handoffs = snap.handoffs;
941
- this.ops = snap.ops;
942
- this.handoffSeq = snap.handoffSeq;
943
- throw err;
944
- }
945
- }
946
- /** The F3 in-tx ledger write for completeStageRun / failStageRun. */
947
- writeLedger(run, w) {
948
- if (!w.handoff) return;
949
- const p = w.handoff.payload;
950
- const conflict = this.findConflict(p.cardId, run.stageId, this.versionOf(run.idemKey), p.kind, w.handoff.dedupeKey);
951
- if (conflict) return;
952
- const row = this.insertHandoff({
953
- payload: p,
954
- stageId: run.stageId,
955
- configVersion: this.versionOf(run.idemKey),
956
- idemKey: run.idemKey,
957
- attempt: run.attempts ?? 1,
958
- dedupeKey: w.handoff.dedupeKey
959
- });
960
- if (w.notifyOpId && !this.ops.has(w.notifyOpId)) {
961
- this.ops.set(w.notifyOpId, {
962
- opId: w.notifyOpId,
963
- kind: "notify.in_app",
964
- cardId: p.cardId,
965
- handoffId: row.record.id,
966
- payload: { handoffId: row.record.id },
967
- status: "pending",
968
- attempts: 0
969
- });
928
+ parse(res, requestedVersion) {
929
+ if (!res?.config?.uuid) {
930
+ throw new Error("Invalid board-config response: missing config.uuid");
970
931
  }
971
- }
972
- findConflict(cardId, stageId, configVersion, kind, dedupeKey) {
973
- if (dedupeKey) {
974
- const d = this.handoffs.find((r) => r.dedupeKey === dedupeKey);
975
- if (d) return d;
932
+ let definition;
933
+ let version;
934
+ if (res.version && typeof res.version === "object") {
935
+ definition = res.version.definition;
936
+ version = res.version.version;
937
+ } else if (isVersionRow(res.definition)) {
938
+ definition = res.definition.definition;
939
+ version = res.definition.version ?? res.config.activeVersion ?? void 0;
940
+ } else {
941
+ definition = res.definition;
942
+ version = typeof res.version === "number" ? res.version : res.config.activeVersion ?? void 0;
976
943
  }
977
- if (kind === "BLOCKED") {
978
- return this.handoffs.find(
979
- (r) => r.record.kind === "BLOCKED" && r.record.status === "open" && r.record.cardId === cardId && r.stageId === stageId && r.configVersion === configVersion
980
- );
944
+ if (!definition) {
945
+ throw new Error("Invalid board-config response: missing definition");
981
946
  }
982
- return void 0;
947
+ const resolvedVersion = version ?? requestedVersion ?? 1;
948
+ return mapDefinitionToBoard(res.config, definition, resolvedVersion);
983
949
  }
984
- insertHandoff(r) {
985
- this.handoffSeq += 1;
986
- const row = {
987
- record: { ...structuredClone(r.payload), status: "open", consumedAt: null },
988
- stageId: r.stageId,
989
- configVersion: r.configVersion,
990
- idemKey: r.idemKey,
991
- attempt: r.attempt,
992
- dedupeKey: r.dedupeKey,
993
- seq: this.handoffSeq
994
- };
995
- this.handoffs.push(row);
996
- return row;
950
+ };
951
+ function toResolvedLifecycle(cfg) {
952
+ return {
953
+ uuid: cfg.uuid,
954
+ version: cfg.version,
955
+ source: "cadra",
956
+ name: cfg.name,
957
+ slug: cfg.slug,
958
+ stages: cfg.board.lanes,
959
+ transitions: cfg.board.transitions ?? [],
960
+ board: cfg.board
961
+ };
962
+ }
963
+ var CadraLifecycleSource = class {
964
+ client;
965
+ constructor(arg) {
966
+ this.client = arg instanceof BoardConfigClient ? arg : new BoardConfigClient(arg);
967
+ }
968
+ /** The ACTIVE version, projected to a `ResolvedLifecycle` (`source: cadra`). */
969
+ async getActive(uuid) {
970
+ return toResolvedLifecycle(await this.client.getActive(uuid));
971
+ }
972
+ /** A PINNED version, projected to a `ResolvedLifecycle` (`source: cadra`). */
973
+ async getPinned(uuid, version) {
974
+ return toResolvedLifecycle(await this.client.getPinned(uuid, version));
975
+ }
976
+ /** Drop the underlying cache (e.g. after a known publish). */
977
+ clearCache() {
978
+ this.client.clearCache();
997
979
  }
998
- toV1(rec) {
999
- const { status: _s, consumedAt: _c, ...v1 } = structuredClone(rec);
1000
- return v1;
980
+ };
981
+
982
+ // src/kanban/engine/handlers.ts
983
+ var WebhookDeliveryError = class extends Error {
984
+ constructor(message, status, retryable) {
985
+ super(message);
986
+ this.status = status;
987
+ this.retryable = retryable;
988
+ this.name = "WebhookDeliveryError";
1001
989
  }
1002
- /** The trailing config version of an idemKey (`cardId:stageId:version`). */
1003
- versionOf(idemKey) {
1004
- return Number(idemKey.slice(idemKey.lastIndexOf(":") + 1));
990
+ status;
991
+ retryable;
992
+ };
993
+ async function hmacSign(secret, payload) {
994
+ const enc = new TextEncoder();
995
+ const key = await crypto.subtle.importKey(
996
+ "raw",
997
+ enc.encode(secret),
998
+ { name: "HMAC", hash: "SHA-256" },
999
+ false,
1000
+ ["sign"]
1001
+ );
1002
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload));
1003
+ return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
1004
+ }
1005
+ var sleepDefault = (ms) => new Promise((r) => setTimeout(r, ms));
1006
+ async function deliverWebhook(ctx, cadra, opts = {}) {
1007
+ const binding = ctx.stage.handler?.webhook;
1008
+ if (!binding) throw new Error(`Stage ${ctx.stage.key} has no webhook binding`);
1009
+ const doFetch = opts.fetchImpl ?? fetch;
1010
+ const maxAttempts = opts.maxAttempts ?? binding.retry ?? 3;
1011
+ const baseBackoff = opts.baseBackoffMs ?? 200;
1012
+ const sleep = opts.sleep ?? sleepDefault;
1013
+ const now = opts.now ?? Date.now;
1014
+ const stageId = ctx.stage.id ?? ctx.stage.key;
1015
+ const secretMaterial = await cadra.getWebhookSecret({
1016
+ boardConfigUuid: ctx.boardConfigUuid,
1017
+ stageId
1018
+ });
1019
+ const timestamp = now();
1020
+ const body = {
1021
+ idemKey: ctx.idemKey,
1022
+ cardId: ctx.cardId,
1023
+ boardConfigUuid: ctx.boardConfigUuid,
1024
+ configVersion: ctx.configVersion,
1025
+ stageId,
1026
+ input: ctx.input,
1027
+ callbackUrl: ctx.callbackUrl,
1028
+ callbackToken: secretMaterial?.callbackToken,
1029
+ timestamp
1030
+ };
1031
+ const payload = JSON.stringify(body);
1032
+ const signature = secretMaterial?.secret ? await hmacSign(secretMaterial.secret, `${timestamp}.${payload}`) : void 0;
1033
+ const headers = {
1034
+ "Content-Type": "application/json",
1035
+ "Idempotency-Key": ctx.idemKey,
1036
+ ...binding.headers ?? {}
1037
+ };
1038
+ if (signature) {
1039
+ headers["X-Cadra-Signature"] = `t=${timestamp},v1=${signature}`;
1005
1040
  }
1006
- /**
1007
- * Reject (throw) if any node in the rule tree is not a known `kind` — checked
1008
- * up front so a bad rule fails closed before we ever inspect a card.
1009
- */
1010
- assertKnownKind(rule) {
1011
- switch (rule.kind) {
1012
- case "always":
1013
- case "backlog":
1014
- case "field_due":
1015
- case "predicate":
1016
- return;
1017
- case "and":
1018
- for (const r of rule.rules) this.assertKnownKind(r);
1019
- return;
1020
- default:
1021
- throw new UnknownDueRuleError(
1022
- rule.kind ?? "<missing>"
1041
+ let attempt = 0;
1042
+ while (true) {
1043
+ attempt += 1;
1044
+ try {
1045
+ const res = await doFetch(binding.url, {
1046
+ method: "POST",
1047
+ headers,
1048
+ body: payload,
1049
+ ...binding.timeoutMs ? { signal: AbortSignal.timeout(binding.timeoutMs) } : {}
1050
+ });
1051
+ if (res.ok) return;
1052
+ if (res.status >= 400 && res.status < 500) {
1053
+ throw new WebhookDeliveryError(
1054
+ `Webhook ${binding.url} returned ${res.status}`,
1055
+ res.status,
1056
+ false
1057
+ );
1058
+ }
1059
+ throw new WebhookDeliveryError(
1060
+ `Webhook ${binding.url} returned ${res.status}`,
1061
+ res.status,
1062
+ true
1063
+ );
1064
+ } catch (err) {
1065
+ const retryable = err instanceof WebhookDeliveryError ? err.retryable : true;
1066
+ if (!retryable || attempt >= maxAttempts) {
1067
+ if (err instanceof WebhookDeliveryError) throw err;
1068
+ throw new WebhookDeliveryError(
1069
+ err instanceof Error ? err.message : String(err),
1070
+ void 0,
1071
+ true
1023
1072
  );
1073
+ }
1074
+ await sleep(baseBackoff * 2 ** (attempt - 1));
1024
1075
  }
1025
1076
  }
1026
- /**
1027
- * Evaluate a (validated) `DueRule` against an in-memory card. `field_due` /
1028
- * `predicate` read the card's `sharedContext` (the reference adapter's only
1029
- * "host data"); a real host pushes these down to SQL over its own columns.
1030
- */
1031
- isDue(card, rule, now) {
1032
- switch (rule.kind) {
1033
- case "always":
1034
- return true;
1035
- case "backlog":
1036
- return true;
1037
- case "field_due": {
1038
- const v = card.sharedContext?.[rule.field];
1039
- if (v == null) return false;
1040
- const left = this.toComparable(v);
1041
- const right = rule.value === "now" ? now : this.toComparable(rule.value);
1042
- if (left == null || right == null) return false;
1043
- return left <= right;
1077
+ }
1078
+ async function dispatchStageHandler(ctx, cadra, webhookOpts) {
1079
+ const handler = ctx.stage.handler;
1080
+ if (!handler || handler.type === "none" || handler.type === "human") {
1081
+ return { dispatched: false };
1082
+ }
1083
+ const runIdemKey = ctx.batch?.batchKey ?? ctx.idemKey;
1084
+ switch (handler.type) {
1085
+ case "cadra_agent": {
1086
+ if (!handler.agentUuid) {
1087
+ throw new Error(`Stage ${ctx.stage.key} (cadra_agent) missing agentUuid`);
1044
1088
  }
1045
- case "predicate": {
1046
- const v = card.sharedContext?.[rule.ref];
1047
- if (v == null) return false;
1048
- return String(v) === String(rule.value);
1089
+ const { executionUuid } = await cadra.runAgent({
1090
+ agentUuid: handler.agentUuid,
1091
+ orgId: ctx.orgId,
1092
+ input: ctx.input,
1093
+ idemKey: runIdemKey,
1094
+ // Board/headless dispatch always disables ask_user (spec §6.5 / FINDING-5).
1095
+ allowAskUser: false,
1096
+ callbackRef: ctx.callbackUrl
1097
+ });
1098
+ return { dispatched: true, executionUuid };
1099
+ }
1100
+ case "cadra_team": {
1101
+ if (!handler.teamUuid) {
1102
+ throw new Error(`Stage ${ctx.stage.key} (cadra_team) missing teamUuid`);
1049
1103
  }
1050
- case "and":
1051
- return rule.rules.every((r) => this.isDue(card, r, now));
1052
- default:
1053
- throw new UnknownDueRuleError(
1054
- rule.kind ?? "<missing>"
1055
- );
1104
+ const { executionUuid } = await cadra.runTeam({
1105
+ teamUuid: handler.teamUuid,
1106
+ orgId: ctx.orgId,
1107
+ input: ctx.input,
1108
+ idemKey: runIdemKey,
1109
+ // Board/headless dispatch always disables ask_user (spec §6.5 / FINDING-5).
1110
+ allowAskUser: false,
1111
+ callbackRef: ctx.callbackUrl
1112
+ });
1113
+ return { dispatched: true, executionUuid };
1056
1114
  }
1057
- }
1058
- /**
1059
- * Coerce a value to a comparable primitive for `field_due` (`<=`): dates →
1060
- * epoch ms, numeric strings → numbers, else the raw number/string.
1061
- */
1062
- toComparable(v) {
1063
- if (typeof v === "number") return v;
1064
- if (v instanceof Date) return v.getTime();
1065
- if (typeof v === "string") {
1066
- const t = Date.parse(v);
1067
- if (!Number.isNaN(t)) return t;
1068
- const n = Number(v);
1069
- if (!Number.isNaN(n) && v.trim() !== "") return n;
1070
- return v;
1115
+ case "webhook": {
1116
+ await deliverWebhook(ctx, cadra, webhookOpts);
1117
+ return { dispatched: true };
1071
1118
  }
1072
- return null;
1073
- }
1074
- requireCard(cardId) {
1075
- const c = this.cards.get(cardId);
1076
- if (!c) throw new Error(`Card not found: ${cardId}`);
1077
- return c;
1078
- }
1079
- appendLog(cardId, entry) {
1080
- const list = this.logs.get(cardId) ?? [];
1081
- list.push({
1082
- id: nextId("log"),
1083
- cardId,
1084
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1085
- ...entry
1086
- });
1087
- this.logs.set(cardId, list);
1088
- }
1089
- publicCard(c) {
1090
- const { boardConfigUuid: _b, ...pub } = c;
1091
- return { ...pub };
1119
+ case "remote_agent": {
1120
+ const h = handler;
1121
+ const remoteAgentUuid = h["remoteAgentUuid"] ?? h["remote_agent_uuid"];
1122
+ if (!remoteAgentUuid) {
1123
+ throw new Error(`Stage ${ctx.stage.key} (remote_agent) missing remoteAgentUuid`);
1124
+ }
1125
+ const { executionUuid } = await cadra.runRemoteAgent({
1126
+ remoteAgentUuid,
1127
+ orgId: ctx.orgId,
1128
+ input: ctx.input,
1129
+ idemKey: runIdemKey,
1130
+ // Board/headless dispatch always disables ask_user (spec §6.5).
1131
+ allowAskUser: false,
1132
+ callbackRef: ctx.callbackUrl
1133
+ });
1134
+ return { dispatched: true, executionUuid };
1135
+ }
1136
+ default:
1137
+ return { dispatched: false };
1092
1138
  }
1093
- };
1139
+ }
1094
1140
 
1095
1141
  // src/kanban/adapters/http-adapter.ts
1096
1142
  var HttpAdapterError = class extends Error {
@@ -2313,6 +2359,7 @@ function createEngine(options) {
2313
2359
  async function runStage(cardId, laneKey, internal = {}) {
2314
2360
  const card = await adapter.getCard(cardId);
2315
2361
  if (!card) throw new Error(`Card not found: ${cardId}`);
2362
+ if (internal.requireLane && card.lane !== laneKey) return { dispatched: false };
2316
2363
  const cfg = await pinnedConfigFor(card);
2317
2364
  const stage = laneByKey(cfg, laneKey);
2318
2365
  if (!stage) throw new Error(`Stage "${laneKey}" not in config ${cfg.uuid}`);
@@ -2497,7 +2544,7 @@ ${renderHandoffForPrompt(items)}`;
2497
2544
  async function dispatchOnEntry(cardId, laneKey, cfg) {
2498
2545
  const stage = laneByKey(cfg, laneKey);
2499
2546
  if (hasAutomatedHandler(stage) && dispatchesOnEntry(stage)) {
2500
- return runStage(cardId, laneKey);
2547
+ return runStage(cardId, laneKey, { requireLane: true });
2501
2548
  }
2502
2549
  return { dispatched: false };
2503
2550
  }
@@ -2522,6 +2569,41 @@ ${renderHandoffForPrompt(items)}`;
2522
2569
  await dispatchOnEntry(cardId, moved.lane, cfg);
2523
2570
  return moved;
2524
2571
  }
2572
+ async function hostMove(cardId, toLane, actor, opts) {
2573
+ const card = await adapter.getCard(cardId);
2574
+ if (!card) throw new Error(`Card not found: ${cardId}`);
2575
+ const cfg = await pinnedConfigFor(card);
2576
+ const toStage = laneByKey(cfg, toLane);
2577
+ if (!toStage) {
2578
+ throw new Error(`Lane "${toLane}" not in config ${cfg.uuid} v${cfg.version}`);
2579
+ }
2580
+ if (card.lane === toLane) return card;
2581
+ const fromLane = opts.expectedFromLane;
2582
+ if (card.lane !== fromLane) {
2583
+ throw new TransitionConflictError(`Expected lane ${fromLane}, card is in ${card.lane}`);
2584
+ }
2585
+ const edge = (cfg.board.transitions ?? []).find(
2586
+ (t) => t.fromLane === fromLane && t.toLane === toLane
2587
+ );
2588
+ const moved = await adapter.applyTransition(cardId, {
2589
+ fromLane,
2590
+ toLane,
2591
+ transitionId: edge?.id ?? `host:${fromLane}->${toLane}`,
2592
+ actor,
2593
+ hostObservation: {
2594
+ ...opts.externalMeta !== void 0 ? { externalMeta: opts.externalMeta } : {},
2595
+ ...opts.observedAt !== void 0 ? { observedAt: opts.observedAt } : {}
2596
+ }
2597
+ });
2598
+ if (ledger) {
2599
+ const fromStage = laneByKey(cfg, fromLane);
2600
+ if (!hasAutomatedHandler(fromStage) && hasAutomatedHandler(toStage)) {
2601
+ await writeHumanToAi(card, moved, toStage, cfg, actor);
2602
+ }
2603
+ }
2604
+ await dispatchOnEntry(cardId, moved.lane, cfg);
2605
+ return moved;
2606
+ }
2525
2607
  async function writeHumanToAi(before, moved, toStage, cfg, actor) {
2526
2608
  if (!ledger) return;
2527
2609
  const recent = await ledger.listHandoffs(before.id, { limit: 50 });
@@ -2560,13 +2642,20 @@ ${renderHandoffForPrompt(items)}`;
2560
2642
  if (!initialLane) {
2561
2643
  throw new Error(`Config ${boardConfigUuid} has no lanes`);
2562
2644
  }
2645
+ if (input.externalRef !== void 0 && adapter.getCardByExternalRef) {
2646
+ const existing = await adapter.getCardByExternalRef(boardConfigUuid, input.externalRef);
2647
+ if (existing) return existing;
2648
+ }
2563
2649
  const card = await adapter.createCard({
2564
2650
  ...input,
2565
2651
  boardConfigUuid,
2566
2652
  configVersion: cfg.version,
2567
2653
  initialLane
2568
2654
  });
2569
- pinnedVersions.set(card.id, cfg.version);
2655
+ if (input.externalRef !== void 0 && card.lane !== initialLane) {
2656
+ return card;
2657
+ }
2658
+ pinnedVersions.set(card.id, card.configVersion ?? cfg.version);
2570
2659
  await dispatchOnEntry(card.id, initialLane, cfg);
2571
2660
  return await adapter.getCard(card.id) ?? card;
2572
2661
  }
@@ -2855,6 +2944,7 @@ ${renderHandoffForPrompt(items)}`;
2855
2944
  return {
2856
2945
  createCard,
2857
2946
  transition,
2947
+ hostMove,
2858
2948
  runStage: async (cardId, laneKey) => {
2859
2949
  const { runStatus: _rs, ...pub } = await runStage(cardId, laneKey);
2860
2950
  return pub;
@@ -2887,6 +2977,7 @@ exports.HANDOFF_PROMPT_PREAMBLE = HANDOFF_PROMPT_PREAMBLE;
2887
2977
  exports.HANDOFF_VERDICT_JSON_SCHEMA = HANDOFF_VERDICT_JSON_SCHEMA;
2888
2978
  exports.HANDOFF_VERDICT_TASK_SUFFIX = HANDOFF_VERDICT_TASK_SUFFIX;
2889
2979
  exports.HandoffAnswerError = HandoffAnswerError;
2980
+ exports.HostFenceError = HostFenceError;
2890
2981
  exports.HttpAdapterError = HttpAdapterError;
2891
2982
  exports.HttpStorageAdapter = HttpStorageAdapter;
2892
2983
  exports.InMemoryStorageAdapter = InMemoryStorageAdapter;