@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.
- package/README.md +104 -53
- package/dist/ai-functions/react/index.cjs +176 -0
- package/dist/ai-functions/react/index.cjs.map +1 -0
- package/dist/ai-functions/react/index.d.cts +129 -0
- package/dist/ai-functions/react/index.d.ts +129 -0
- package/dist/ai-functions/react/index.js +153 -0
- package/dist/ai-functions/react/index.js.map +1 -0
- package/dist/ai-functions/server/index.cjs +296 -0
- package/dist/ai-functions/server/index.cjs.map +1 -0
- package/dist/ai-functions/server/index.d.cts +334 -0
- package/dist/ai-functions/server/index.d.ts +334 -0
- package/dist/ai-functions/server/index.js +284 -0
- package/dist/ai-functions/server/index.js.map +1 -0
- package/dist/board-host/index.cjs +217 -129
- package/dist/board-host/index.cjs.map +1 -1
- package/dist/board-host/index.d.cts +96 -1
- package/dist/board-host/index.d.ts +96 -1
- package/dist/board-host/index.js +218 -131
- package/dist/board-host/index.js.map +1 -1
- package/dist/board-host/server/index.d.cts +44 -0
- package/dist/board-host/server/index.d.ts +44 -0
- package/dist/chat/index.cjs +299 -17
- package/dist/chat/index.cjs.map +1 -1
- package/dist/chat/index.d.cts +225 -0
- package/dist/chat/index.d.ts +225 -0
- package/dist/chat/index.js +299 -17
- package/dist/chat/index.js.map +1 -1
- package/dist/index.cjs +221 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +286 -1
- package/dist/index.d.ts +286 -1
- package/dist/index.js +215 -4
- package/dist/index.js.map +1 -1
- package/dist/kanban/engine/index.cjs +774 -683
- package/dist/kanban/engine/index.cjs.map +1 -1
- package/dist/kanban/engine/index.d.cts +90 -1
- package/dist/kanban/engine/index.d.ts +90 -1
- package/dist/kanban/engine/index.js +774 -684
- package/dist/kanban/engine/index.js.map +1 -1
- package/dist/kanban/index.cjs +214 -3
- package/dist/kanban/index.cjs.map +1 -1
- package/dist/kanban/index.d.cts +252 -0
- package/dist/kanban/index.d.ts +252 -0
- package/dist/kanban/index.js +214 -3
- package/dist/kanban/index.js.map +1 -1
- package/dist/teams/index.cjs +214 -3
- package/dist/teams/index.cjs.map +1 -1
- package/dist/teams/index.d.cts +225 -0
- package/dist/teams/index.d.ts +225 -0
- package/dist/teams/index.js +214 -3
- package/dist/teams/index.js.map +1 -1
- 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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
789
|
-
*
|
|
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
|
-
|
|
794
|
-
if (
|
|
795
|
-
if (
|
|
796
|
-
|
|
797
|
-
|
|
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
|
|
743
|
+
return null;
|
|
800
744
|
}
|
|
801
|
-
|
|
802
|
-
const
|
|
803
|
-
if (!
|
|
804
|
-
|
|
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
|
-
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
837
|
-
const
|
|
838
|
-
return
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
909
|
+
// -------------------------------------------------------------------------
|
|
910
|
+
// internals
|
|
911
|
+
// -------------------------------------------------------------------------
|
|
912
|
+
key(uuid, version) {
|
|
913
|
+
return `${uuid}:${version}`;
|
|
907
914
|
}
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
return
|
|
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
|
-
*
|
|
923
|
-
*
|
|
924
|
+
* Normalize the wire envelope (active `{ config, definition, version }` OR
|
|
925
|
+
* pinned `{ config, version: { version, definition } }`) into a resolved
|
|
926
|
+
* config.
|
|
924
927
|
*/
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
973
|
-
if (
|
|
974
|
-
|
|
975
|
-
|
|
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 (
|
|
978
|
-
|
|
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
|
-
|
|
947
|
+
const resolvedVersion = version ?? requestedVersion ?? 1;
|
|
948
|
+
return mapDefinitionToBoard(res.config, definition, resolvedVersion);
|
|
983
949
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
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;
|