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