@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
|
@@ -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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
783
|
-
*
|
|
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
|
-
|
|
788
|
-
if (
|
|
789
|
-
if (
|
|
790
|
-
|
|
791
|
-
|
|
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
|
|
737
|
+
return null;
|
|
794
738
|
}
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
if (!
|
|
798
|
-
|
|
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
|
-
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
831
|
-
const
|
|
832
|
-
return
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
903
|
+
// -------------------------------------------------------------------------
|
|
904
|
+
// internals
|
|
905
|
+
// -------------------------------------------------------------------------
|
|
906
|
+
key(uuid, version) {
|
|
907
|
+
return `${uuid}:${version}`;
|
|
901
908
|
}
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
return
|
|
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
|
-
*
|
|
917
|
-
*
|
|
918
|
+
* Normalize the wire envelope (active `{ config, definition, version }` OR
|
|
919
|
+
* pinned `{ config, version: { version, definition } }`) into a resolved
|
|
920
|
+
* config.
|
|
918
921
|
*/
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
967
|
-
if (
|
|
968
|
-
|
|
969
|
-
|
|
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 (
|
|
972
|
-
|
|
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
|
-
|
|
941
|
+
const resolvedVersion = version ?? requestedVersion ?? 1;
|
|
942
|
+
return mapDefinitionToBoard(res.config, definition, resolvedVersion);
|
|
977
943
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
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
|