@wopr-network/defcon 0.2.2 → 1.0.1

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.
@@ -19,6 +19,8 @@ export interface ProcessSignalResult {
19
19
  export interface ClaimWorkResult {
20
20
  entityId: string;
21
21
  invocationId: string;
22
+ flowName: string;
23
+ stage: string;
22
24
  prompt: string;
23
25
  context: Record<string, unknown> | null;
24
26
  }
@@ -53,16 +55,8 @@ export declare class Engine {
53
55
  id: string;
54
56
  [key: string]: unknown;
55
57
  }>): Promise<Entity>;
56
- claimWork(role: string, flowName?: string, worker_id?: string): Promise<ClaimWorkResult | null>;
57
- /**
58
- * Try to claim an existing unclaimed invocation for an already-claimed entity.
59
- * Handles the race condition where another worker claims the invocation first
60
- * (releases the entity claim and returns null so the caller can try the next candidate).
61
- */
62
- private tryClaimInvocation;
63
- private setAffinityIfNeeded;
64
- private buildPrompt;
65
- private emitAndReturn;
58
+ claimWork(role: string, flowName?: string, worker_id?: string): Promise<ClaimWorkResult | "all_claimed" | null>;
59
+ private buildPromptForEntity;
66
60
  getStatus(): Promise<EngineStatus>;
67
61
  startReaper(intervalMs: number, entityTtlMs?: number): () => Promise<void>;
68
62
  private checkConcurrency;
@@ -1,3 +1,4 @@
1
+ import { NotFoundError, ValidationError } from "../errors.js";
1
2
  import { consoleLogger } from "../logger.js";
2
3
  import { DEFAULT_TIMEOUT_PROMPT } from "./constants.js";
3
4
  import { executeSpawn } from "./flow-spawner.js";
@@ -29,21 +30,21 @@ export class Engine {
29
30
  // 1. Load entity
30
31
  const entity = await this.entityRepo.get(entityId);
31
32
  if (!entity)
32
- throw new Error(`Entity "${entityId}" not found`);
33
+ throw new NotFoundError(`Entity "${entityId}" not found`);
33
34
  // 2. Load flow
34
35
  const flow = await this.flowRepo.get(entity.flowId);
35
36
  if (!flow)
36
- throw new Error(`Flow "${entity.flowId}" not found`);
37
+ throw new NotFoundError(`Flow "${entity.flowId}" not found`);
37
38
  // 3. Find transition
38
39
  const transition = findTransition(flow, entity.state, signal, { entity }, true, this.logger);
39
40
  if (!transition)
40
- throw new Error(`No transition from "${entity.state}" on signal "${signal}" in flow "${flow.name}"`);
41
+ throw new ValidationError(`No transition from "${entity.state}" on signal "${signal}" in flow "${flow.name}"`);
41
42
  // 4. Evaluate gate if present
42
43
  const gatesPassed = [];
43
44
  if (transition.gateId) {
44
45
  const gate = await this.gateRepo.get(transition.gateId);
45
46
  if (!gate)
46
- throw new Error(`Gate "${transition.gateId}" not found`);
47
+ throw new NotFoundError(`Gate "${transition.gateId}" not found`);
47
48
  const gateResult = await evaluateGate(gate, entity, this.gateRepo, flow.gateTimeoutMs);
48
49
  if (!gateResult.passed) {
49
50
  // Persist gate failure into entity artifacts for retry context
@@ -243,7 +244,7 @@ export class Engine {
243
244
  async createEntity(flowName, refs) {
244
245
  const flow = await this.flowRepo.getByName(flowName);
245
246
  if (!flow)
246
- throw new Error(`Flow "${flowName}" not found`);
247
+ throw new NotFoundError(`Flow "${flowName}" not found`);
247
248
  const entity = await this.entityRepo.create(flow.id, flow.initialState, refs);
248
249
  await this.eventEmitter.emit({
249
250
  type: "entity.created",
@@ -264,7 +265,7 @@ export class Engine {
264
265
  error: onEnterResult.error,
265
266
  emittedAt: new Date(),
266
267
  });
267
- throw new Error(`onEnter failed for entity ${entity.id}: ${onEnterResult.error}`);
268
+ throw new ValidationError(`onEnter failed for entity ${entity.id}: ${onEnterResult.error}`);
268
269
  }
269
270
  if (onEnterResult.artifacts) {
270
271
  await this.eventEmitter.emit({
@@ -295,89 +296,238 @@ export class Engine {
295
296
  return entity;
296
297
  }
297
298
  async claimWork(role, flowName, worker_id) {
299
+ // 1. Find candidate flows filtered by discipline
298
300
  let flows;
299
301
  if (flowName) {
300
302
  const flow = await this.flowRepo.getByName(flowName);
301
- // Validate discipline match — null discipline flows are claimable by any role
302
- flows = flow && (flow.discipline === null || flow.discipline === role) ? [flow] : [];
303
+ if (!flow)
304
+ throw new Error(`Flow "${flowName}" not found`);
305
+ flows = flow.discipline === null || flow.discipline === role ? [flow] : [];
303
306
  }
304
307
  else {
305
308
  const allFlows = await this.flowRepo.listAll();
306
309
  flows = allFlows.filter((f) => f.discipline === null || f.discipline === role);
307
310
  }
311
+ if (flows.length === 0)
312
+ return null;
313
+ const candidates = [];
314
+ const affinityInvocationIds = new Set();
308
315
  for (const flow of flows) {
309
- // Try affinity match first if worker_id provided
316
+ // Affinity candidates first (if worker_id provided); capture IDs for step 4
310
317
  if (worker_id) {
311
318
  const affinityUnclaimed = await this.invocationRepo.findUnclaimedWithAffinity(flow.id, role, worker_id);
312
- for (const pending of affinityUnclaimed) {
313
- const claimed = await this.entityRepo.claimById(pending.entityId, `agent:${role}`);
314
- if (!claimed)
315
- continue;
316
- const result = await this.tryClaimInvocation(pending, claimed, flow, role, worker_id);
317
- if (result)
318
- return result;
319
+ for (const inv of affinityUnclaimed) {
320
+ candidates.push({ invocation: inv, flow });
321
+ affinityInvocationIds.add(inv.id);
319
322
  }
320
323
  }
321
- // Prefer claiming an existing unclaimed invocation created by processSignal
322
- // to avoid creating a duplicate. Fall back to creating a new one if none exist.
323
324
  const unclaimed = await this.invocationRepo.findUnclaimedByFlow(flow.id);
324
- for (const pending of unclaimed) {
325
- const claimed = await this.entityRepo.claim(flow.id, pending.stage, `agent:${role}`);
326
- if (!claimed)
327
- continue;
328
- const result = await this.tryClaimInvocation(pending, claimed, flow, role, worker_id);
329
- if (result)
330
- return result;
325
+ for (const inv of unclaimed)
326
+ candidates.push({ invocation: inv, flow });
327
+ }
328
+ // 3. Load entities for priority sorting + dedup candidates
329
+ const entityMap = new Map();
330
+ const uniqueEntityIds = [...new Set(candidates.map((c) => c.invocation.entityId))];
331
+ await Promise.all(uniqueEntityIds.map(async (eid) => {
332
+ const entity = await this.entityRepo.get(eid);
333
+ if (entity)
334
+ entityMap.set(eid, entity);
335
+ }));
336
+ // 4. Build affinity set for priority sorting using IDs already collected in step 2 (no re-query)
337
+ const affinitySet = new Set();
338
+ if (worker_id) {
339
+ for (const c of candidates) {
340
+ if (affinityInvocationIds.has(c.invocation.id)) {
341
+ affinitySet.add(c.invocation.entityId);
342
+ }
343
+ }
344
+ }
345
+ // 5. Sort: affinity → entity priority → time in state (oldest first)
346
+ const now = Date.now();
347
+ candidates.sort((a, b) => {
348
+ const affinityA = affinitySet.has(a.invocation.entityId) ? 1 : 0;
349
+ const affinityB = affinitySet.has(b.invocation.entityId) ? 1 : 0;
350
+ if (affinityA !== affinityB)
351
+ return affinityB - affinityA;
352
+ const entityA = entityMap.get(a.invocation.entityId);
353
+ const entityB = entityMap.get(b.invocation.entityId);
354
+ const priA = entityA?.priority ?? 0;
355
+ const priB = entityB?.priority ?? 0;
356
+ if (priA !== priB)
357
+ return priB - priA;
358
+ const timeA = entityA?.createdAt?.getTime() ?? now;
359
+ const timeB = entityB?.createdAt?.getTime() ?? now;
360
+ return timeA - timeB;
361
+ });
362
+ // 6. Dedup: only try each invocation once (affinity candidates may overlap with unclaimed)
363
+ const seen = new Set();
364
+ const deduped = candidates.filter((c) => {
365
+ if (seen.has(c.invocation.id))
366
+ return false;
367
+ seen.add(c.invocation.id);
368
+ return true;
369
+ });
370
+ // 7. Try claiming in priority order (entity-first for safe locking)
371
+ for (const { invocation: pending, flow } of deduped) {
372
+ const entity = entityMap.get(pending.entityId);
373
+ if (!entity)
374
+ continue;
375
+ // Guard: entity state must still match the invocation's stage — if another worker
376
+ // transitioned the entity between candidate fetch and now, skip this candidate.
377
+ if (entity.state !== pending.stage)
378
+ continue;
379
+ const entityClaimToken = worker_id ?? `agent:${role}`;
380
+ const claimed = await this.entityRepo.claimById(entity.id, entityClaimToken);
381
+ if (!claimed)
382
+ continue;
383
+ // Post-claim state validation — entity may have transitioned between guard check and claim
384
+ if (claimed.state !== pending.stage) {
385
+ try {
386
+ await this.entityRepo.release(claimed.id, entityClaimToken);
387
+ }
388
+ catch (releaseErr) {
389
+ this.logger.error(`[engine] release() failed for entity ${claimed.id} after state mismatch:`, releaseErr);
390
+ }
391
+ continue;
392
+ }
393
+ let claimedInvocation;
394
+ try {
395
+ claimedInvocation = await this.invocationRepo.claim(pending.id, worker_id ?? `agent:${role}`);
331
396
  }
332
- // No pre-existing unclaimed invocations — claim entity directly and create invocation
397
+ catch (err) {
398
+ this.logger.error(`[engine] invocationRepo.claim() failed for invocation ${pending.id}:`, err);
399
+ try {
400
+ await this.entityRepo.release(claimed.id, entityClaimToken);
401
+ }
402
+ catch (releaseErr) {
403
+ this.logger.error(`[engine] release() failed for entity ${claimed.id}:`, releaseErr);
404
+ }
405
+ continue;
406
+ }
407
+ if (!claimedInvocation) {
408
+ try {
409
+ await this.entityRepo.release(claimed.id, entityClaimToken);
410
+ }
411
+ catch (err) {
412
+ this.logger.error(`[engine] release() failed for entity ${claimed.id}:`, err);
413
+ }
414
+ continue;
415
+ }
416
+ if (worker_id) {
417
+ const windowMs = flow.affinityWindowMs ?? 300000;
418
+ try {
419
+ await this.entityRepo.setAffinity(claimed.id, worker_id, role, new Date(Date.now() + windowMs));
420
+ }
421
+ catch (err) {
422
+ this.logger.warn(`[engine] setAffinity failed for entity ${claimed.id} worker ${worker_id} — continuing:`, err);
423
+ }
424
+ }
425
+ const state = flow.states.find((s) => s.name === pending.stage);
426
+ const build = state
427
+ ? await this.buildPromptForEntity(state, claimed, flow)
428
+ : { prompt: pending.prompt, context: null };
429
+ await this.eventEmitter.emit({
430
+ type: "entity.claimed",
431
+ entityId: claimed.id,
432
+ flowId: flow.id,
433
+ agentId: worker_id ?? `agent:${role}`,
434
+ emittedAt: new Date(),
435
+ });
436
+ return {
437
+ entityId: claimed.id,
438
+ invocationId: claimedInvocation.id,
439
+ flowName: flow.name,
440
+ stage: pending.stage,
441
+ prompt: build.prompt,
442
+ context: build.context,
443
+ };
444
+ }
445
+ // 8. Fallback: no unclaimed invocations — claim entity directly and create invocation
446
+ for (const flow of flows) {
333
447
  const claimableStates = flow.states.filter((s) => !!s.promptTemplate);
334
448
  for (const state of claimableStates) {
335
- const claimed = await this.entityRepo.claim(flow.id, state.name, `agent:${role}`);
449
+ const claimed = await this.entityRepo.claim(flow.id, state.name, worker_id ?? `agent:${role}`);
336
450
  if (!claimed)
337
451
  continue;
338
- await this.setAffinityIfNeeded(claimed.id, flow, role, worker_id);
339
- const build = await this.buildPrompt(state, claimed, flow);
452
+ const canCreate = await this.checkConcurrency(flow, claimed);
453
+ if (!canCreate) {
454
+ await this.entityRepo.release(claimed.id, worker_id ?? `agent:${role}`);
455
+ continue;
456
+ }
457
+ const build = await this.buildPromptForEntity(state, claimed, flow);
340
458
  const invocation = await this.invocationRepo.create(claimed.id, state.name, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
341
459
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
342
460
  : undefined);
343
- return this.emitAndReturn(claimed, invocation.id, build, flow, role);
461
+ const entityClaimToken = worker_id ?? `agent:${role}`;
462
+ let claimedInvocation;
463
+ try {
464
+ claimedInvocation = await this.invocationRepo.claim(invocation.id, entityClaimToken);
465
+ }
466
+ catch (err) {
467
+ this.logger.error(`[engine] invocationRepo.claim() failed for invocation ${invocation.id}:`, err);
468
+ try {
469
+ await this.invocationRepo.fail(invocation.id, err instanceof Error ? err.message : String(err));
470
+ }
471
+ catch (failErr) {
472
+ this.logger.error(`[engine] invocationRepo.fail() cleanup failed for invocation ${invocation.id}:`, failErr);
473
+ }
474
+ try {
475
+ await this.entityRepo.release(claimed.id, entityClaimToken);
476
+ }
477
+ catch (releaseErr) {
478
+ this.logger.error(`[engine] release() failed for entity ${claimed.id}:`, releaseErr);
479
+ }
480
+ continue;
481
+ }
482
+ if (!claimedInvocation) {
483
+ // Another worker won the race and claimed this invocation — it is healthy.
484
+ // Do NOT call fail(); just release our entity lock and move on.
485
+ try {
486
+ await this.entityRepo.release(claimed.id, entityClaimToken);
487
+ }
488
+ catch (err) {
489
+ this.logger.error(`[engine] release() failed for entity ${claimed.id}:`, err);
490
+ }
491
+ continue;
492
+ }
493
+ if (worker_id) {
494
+ const windowMs = flow.affinityWindowMs ?? 300000;
495
+ try {
496
+ await this.entityRepo.setAffinity(claimed.id, worker_id, role, new Date(Date.now() + windowMs));
497
+ }
498
+ catch (err) {
499
+ this.logger.warn(`[engine] setAffinity failed for entity ${claimed.id} worker ${worker_id} — continuing:`, err);
500
+ }
501
+ }
502
+ await this.eventEmitter.emit({
503
+ type: "entity.claimed",
504
+ entityId: claimed.id,
505
+ flowId: flow.id,
506
+ agentId: worker_id ?? `agent:${role}`,
507
+ emittedAt: new Date(),
508
+ });
509
+ return {
510
+ entityId: claimed.id,
511
+ invocationId: claimedInvocation.id,
512
+ flowName: flow.name,
513
+ stage: state.name,
514
+ prompt: build.prompt,
515
+ context: build.context,
516
+ };
344
517
  }
345
518
  }
346
- return null;
347
- }
348
- /**
349
- * Try to claim an existing unclaimed invocation for an already-claimed entity.
350
- * Handles the race condition where another worker claims the invocation first
351
- * (releases the entity claim and returns null so the caller can try the next candidate).
352
- */
353
- async tryClaimInvocation(pending, claimed, flow, role, worker_id) {
354
- const claimedInvocation = await this.invocationRepo.claim(pending.id, `agent:${role}`);
355
- if (!claimedInvocation) {
356
- try {
357
- await this.entityRepo.release(claimed.id, `agent:${role}`);
358
- }
359
- catch (err) {
360
- this.logger.error(`release() failed for entity ${claimed.id}:`, err);
519
+ // 9. No work claimed — distinguish "all entities busy" (short retry) from "empty backlog" (long retry)
520
+ for (const flow of flows) {
521
+ const claimableStateNames = flow.states.filter((s) => !!s.promptTemplate).map((s) => s.name);
522
+ if (claimableStateNames.length > 0) {
523
+ const hasAny = await this.entityRepo.hasAnyInFlowAndState(flow.id, claimableStateNames);
524
+ if (hasAny)
525
+ return "all_claimed";
361
526
  }
362
- return null;
363
- }
364
- await this.setAffinityIfNeeded(claimed.id, flow, role, worker_id);
365
- const state = flow.states.find((s) => s.name === pending.stage);
366
- const build = state ? await this.buildPrompt(state, claimed, flow) : { prompt: pending.prompt, context: null };
367
- return this.emitAndReturn(claimed, claimedInvocation.id, build, flow, role);
368
- }
369
- async setAffinityIfNeeded(entityId, flow, role, worker_id) {
370
- if (!worker_id)
371
- return;
372
- const affinityWindow = flow.affinityWindowMs ?? 300000;
373
- try {
374
- await this.entityRepo.setAffinity(entityId, worker_id, role, new Date(Date.now() + affinityWindow));
375
- }
376
- catch (err) {
377
- this.logger.warn(`setAffinity failed for entity ${entityId} worker ${worker_id} — continuing:`, err);
378
527
  }
528
+ return null;
379
529
  }
380
- async buildPrompt(state, entity, flow) {
530
+ async buildPromptForEntity(state, entity, flow) {
381
531
  const [invocations, gateResults] = await Promise.all([
382
532
  this.invocationRepo.findByEntity(entity.id),
383
533
  this.gateRepo.resultsFor(entity.id),
@@ -385,21 +535,6 @@ export class Engine {
385
535
  const enriched = { ...entity, invocations, gateResults };
386
536
  return buildInvocation(state, enriched, this.adapters, flow, this.logger);
387
537
  }
388
- async emitAndReturn(entity, invocationId, build, flow, role) {
389
- await this.eventEmitter.emit({
390
- type: "entity.claimed",
391
- entityId: entity.id,
392
- flowId: flow.id,
393
- agentId: `agent:${role}`,
394
- emittedAt: new Date(),
395
- });
396
- return {
397
- entityId: entity.id,
398
- invocationId,
399
- prompt: build.prompt,
400
- context: build.context,
401
- };
402
- }
403
538
  async getStatus() {
404
539
  const allFlows = await this.flowRepo.listAll();
405
540
  const statusData = {};
@@ -1,3 +1,4 @@
1
+ import { NotFoundError } from "../errors.js";
1
2
  import { consoleLogger } from "../logger.js";
2
3
  /**
3
4
  * If the transition has a spawnFlow, look up that flow and create a new entity in it.
@@ -9,7 +10,7 @@ export async function executeSpawn(transition, parentEntity, flowRepo, entityRep
9
10
  return null;
10
11
  const flow = await flowRepo.getByName(transition.spawnFlow);
11
12
  if (!flow)
12
- throw new Error(`Spawn flow "${transition.spawnFlow}" not found`);
13
+ throw new NotFoundError(`Spawn flow "${transition.spawnFlow}" not found`);
13
14
  const childEntity = await entityRepo.create(flow.id, flow.initialState, parentEntity.refs ?? undefined);
14
15
  try {
15
16
  await entityRepo.appendSpawnedChild(parentEntity.id, {
@@ -2,5 +2,6 @@ export interface GateCommandValidation {
2
2
  valid: boolean;
3
3
  resolvedPath: string | null;
4
4
  error: string | null;
5
+ parts: string[] | null;
5
6
  }
6
7
  export declare function validateGateCommand(command: string): GateCommandValidation;
@@ -1,17 +1,33 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { splitShellWords } from "./shell-words.js";
4
5
  // Anchor project root and gates directory to module location, not process.cwd()
5
6
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
6
7
  const PROJECT_ROOT = path.resolve(MODULE_DIR, "../..");
7
8
  const GATES_ROOT = path.resolve(PROJECT_ROOT, "gates");
8
9
  export function validateGateCommand(command) {
9
10
  if (!command || command.trim().length === 0) {
10
- return { valid: false, resolvedPath: null, error: "Gate command is empty" };
11
+ return { valid: false, resolvedPath: null, error: "Gate command is empty", parts: null };
11
12
  }
12
- const executable = command.split(/\s+/)[0];
13
+ let parts;
14
+ try {
15
+ parts = splitShellWords(command);
16
+ }
17
+ catch (err) {
18
+ return {
19
+ valid: false,
20
+ resolvedPath: null,
21
+ error: `Shell parse error: ${err instanceof Error ? err.message : String(err)}`,
22
+ parts: null,
23
+ };
24
+ }
25
+ if (parts.length === 0) {
26
+ return { valid: false, resolvedPath: null, error: "Gate command is empty", parts: null };
27
+ }
28
+ const executable = parts[0];
13
29
  if (path.isAbsolute(executable)) {
14
- return { valid: false, resolvedPath: null, error: "Gate command must not use absolute paths" };
30
+ return { valid: false, resolvedPath: null, error: "Gate command must not use absolute paths", parts: null };
15
31
  }
16
32
  // Resolve relative to project root (command format is gates/<file> from project root)
17
33
  const resolved = path.resolve(PROJECT_ROOT, executable);
@@ -22,6 +38,7 @@ export function validateGateCommand(command) {
22
38
  valid: false,
23
39
  resolvedPath: null,
24
40
  error: `Gate command must start with 'gates/' and resolve inside the gates directory (resolved outside gates/)`,
41
+ parts: null,
25
42
  };
26
43
  }
27
44
  // Resolve symlinks to prevent symlink escape
@@ -32,7 +49,7 @@ export function validateGateCommand(command) {
32
49
  catch {
33
50
  // Path doesn't exist yet (gate script may be deployed separately) — treat lexical check as sufficient
34
51
  // but still reject if it would escape after symlink resolution
35
- return { valid: true, resolvedPath: resolved, error: null };
52
+ return { valid: true, resolvedPath: resolved, error: null, parts };
36
53
  }
37
54
  const realRelative = path.relative(GATES_ROOT, realPath);
38
55
  if (realRelative.startsWith(`..${path.sep}`) || realRelative === ".." || path.isAbsolute(realRelative)) {
@@ -40,7 +57,8 @@ export function validateGateCommand(command) {
40
57
  valid: false,
41
58
  resolvedPath: null,
42
59
  error: `Gate command resolves via symlink to outside the gates directory`,
60
+ parts: null,
43
61
  };
44
62
  }
45
- return { valid: true, resolvedPath: realPath, error: null };
63
+ return { valid: true, resolvedPath: realPath, error: null, parts };
46
64
  }
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { realpathSync } from "node:fs";
3
3
  import { resolve, sep } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { GateError, NotFoundError, ValidationError } from "../errors.js";
5
6
  import { validateGateCommand } from "./gate-command-validator.js";
6
7
  import { checkSsrf } from "./ssrf-guard.js";
7
8
  // Anchor path-traversal checks to the project root. realpathSync resolves symlinks
@@ -50,8 +51,10 @@ export async function evaluateGate(gate, entity, gateRepo, flowGateTimeoutMs) {
50
51
  await gateRepo.record(entity.id, gate.id, false, msg);
51
52
  return { passed: false, timedOut: false, output: msg };
52
53
  }
53
- const [, ...args] = renderedCommand.split(/\s+/);
54
- const resolvedPath = validation.resolvedPath ?? renderedCommand.split(/\s+/)[0];
54
+ // validation.parts is guaranteed non-null when valid is true
55
+ const parts = validation.parts ?? [renderedCommand];
56
+ const [executable, ...args] = parts;
57
+ const resolvedPath = validation.resolvedPath ?? executable;
55
58
  const result = await runCommand(resolvedPath, args, effectiveTimeout);
56
59
  passed = result.exitCode === 0;
57
60
  output = result.output;
@@ -152,7 +155,7 @@ export async function evaluateGate(gate, entity, gateRepo, flowGateTimeoutMs) {
152
155
  }
153
156
  }
154
157
  else {
155
- throw new Error(`Unknown gate type: ${gate.type}`);
158
+ throw new GateError(`Unknown gate type: ${gate.type}`);
156
159
  }
157
160
  await gateRepo.record(entity.id, gate.id, passed, output);
158
161
  return { passed, timedOut, output };
@@ -160,7 +163,7 @@ export async function evaluateGate(gate, entity, gateRepo, flowGateTimeoutMs) {
160
163
  async function runFunction(functionRef, entity, gate, effectiveTimeout) {
161
164
  const lastColon = functionRef.lastIndexOf(":");
162
165
  if (lastColon === -1) {
163
- throw new Error(`Invalid functionRef "${functionRef}" — expected "path:exportName"`);
166
+ throw new ValidationError(`Invalid functionRef "${functionRef}" — expected "path:exportName"`);
164
167
  }
165
168
  const modulePath = functionRef.slice(0, lastColon);
166
169
  const exportName = functionRef.slice(lastColon + 1);
@@ -178,13 +181,13 @@ async function runFunction(functionRef, entity, gate, effectiveTimeout) {
178
181
  realPath = absPath;
179
182
  }
180
183
  if (!realPath.startsWith(GATES_DIR)) {
181
- throw new Error("functionRef must resolve to a path inside the gates/ directory");
184
+ throw new ValidationError("functionRef must resolve to a path inside the gates/ directory");
182
185
  }
183
186
  const moduleUrl = pathToFileURL(realPath).href;
184
187
  const mod = await import(moduleUrl);
185
188
  const fn = mod[exportName];
186
189
  if (typeof fn !== "function") {
187
- throw new Error(`Gate function "${exportName}" not found in ${modulePath}`);
190
+ throw new NotFoundError(`Gate function "${exportName}" not found in ${modulePath}`);
188
191
  }
189
192
  const timeout = effectiveTimeout;
190
193
  let timer;
@@ -15,7 +15,17 @@ hbs.registerHelper("invocation_count", (entity, stage) => String(entity.invocati
15
15
  hbs.registerHelper("gate_passed", (entity, gateName) => (entity.gateResults?.some((g) => g.gateId === gateName && g.passed) ?? false) ? "true" : "");
16
16
  hbs.registerHelper("has_artifact", (entity, key) => entity.artifacts?.[key] !== undefined ? "true" : "");
17
17
  hbs.registerHelper("time_in_state", (entity) => String(Date.now() - new Date(entity.updatedAt).getTime()));
18
- const BUILTIN_HELPERS = new Set(["gt", "lt", "eq", "invocation_count", "gate_passed", "has_artifact", "time_in_state"]);
18
+ hbs.registerHelper("total_invocations", (entity) => String(Array.isArray(entity.invocations) ? entity.invocations.length : 0));
19
+ const BUILTIN_HELPERS = new Set([
20
+ "gt",
21
+ "lt",
22
+ "eq",
23
+ "invocation_count",
24
+ "gate_passed",
25
+ "has_artifact",
26
+ "time_in_state",
27
+ "total_invocations",
28
+ ]);
19
29
  /** Forbidden patterns in templates — OWASP A03 Injection prevention. */
20
30
  const UNSAFE_PATTERN = /\b(lookup|__proto__|constructor|__defineGetter__|__defineSetter__|__lookupGetter__|__lookupSetter__)\b|@root/;
21
31
  /** Validate a template string against the safe subset. Returns true if safe. */
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Split a command string into words, respecting single quotes, double quotes,
3
+ * and backslash escapes. Follows POSIX shell quoting rules (simplified).
4
+ *
5
+ * - Single quotes: preserve everything literally (no escape sequences)
6
+ * - Double quotes: preserve content, but backslash escapes \\ and \"
7
+ * - Backslash outside quotes: escapes the next character
8
+ */
9
+ export declare function splitShellWords(cmd: string): string[];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Split a command string into words, respecting single quotes, double quotes,
3
+ * and backslash escapes. Follows POSIX shell quoting rules (simplified).
4
+ *
5
+ * - Single quotes: preserve everything literally (no escape sequences)
6
+ * - Double quotes: preserve content, but backslash escapes \\ and \"
7
+ * - Backslash outside quotes: escapes the next character
8
+ */
9
+ export function splitShellWords(cmd) {
10
+ const words = [];
11
+ let current = "";
12
+ let i = 0;
13
+ let inWord = false;
14
+ while (i < cmd.length) {
15
+ const ch = cmd[i];
16
+ if (ch === "'") {
17
+ inWord = true;
18
+ i++;
19
+ const start = i;
20
+ while (i < cmd.length && cmd[i] !== "'")
21
+ i++;
22
+ if (i >= cmd.length)
23
+ throw new Error("Unterminated single quote");
24
+ current += cmd.slice(start, i);
25
+ i++; // skip closing quote
26
+ }
27
+ else if (ch === '"') {
28
+ inWord = true;
29
+ i++;
30
+ while (i < cmd.length && cmd[i] !== '"') {
31
+ if (cmd[i] === "\\" && i + 1 < cmd.length) {
32
+ const next = cmd[i + 1];
33
+ if (next === '"' || next === "\\") {
34
+ current += next;
35
+ i += 2;
36
+ }
37
+ else {
38
+ current += cmd[i];
39
+ i++;
40
+ }
41
+ }
42
+ else {
43
+ current += cmd[i];
44
+ i++;
45
+ }
46
+ }
47
+ if (i >= cmd.length)
48
+ throw new Error("Unterminated double quote");
49
+ i++; // skip closing quote
50
+ }
51
+ else if (ch === "\\" && i + 1 < cmd.length) {
52
+ inWord = true;
53
+ current += cmd[i + 1];
54
+ i += 2;
55
+ }
56
+ else if (ch === " " || ch === "\t") {
57
+ if (inWord) {
58
+ words.push(current);
59
+ current = "";
60
+ inWord = false;
61
+ }
62
+ i++;
63
+ }
64
+ else {
65
+ inWord = true;
66
+ current += ch;
67
+ i++;
68
+ }
69
+ }
70
+ if (inWord) {
71
+ words.push(current);
72
+ }
73
+ return words;
74
+ }
@@ -0,0 +1,13 @@
1
+ export declare class DefconError extends Error {
2
+ constructor(msg: string);
3
+ }
4
+ export declare class NotFoundError extends DefconError {
5
+ }
6
+ export declare class ConflictError extends DefconError {
7
+ }
8
+ export declare class ValidationError extends DefconError {
9
+ }
10
+ export declare class GateError extends DefconError {
11
+ }
12
+ export declare class InternalError extends DefconError {
13
+ }
@@ -0,0 +1,17 @@
1
+ export class DefconError extends Error {
2
+ constructor(msg) {
3
+ super(msg);
4
+ this.name = new.target.name;
5
+ Object.setPrototypeOf(this, new.target.prototype);
6
+ }
7
+ }
8
+ export class NotFoundError extends DefconError {
9
+ }
10
+ export class ConflictError extends DefconError {
11
+ }
12
+ export class ValidationError extends DefconError {
13
+ }
14
+ export class GateError extends DefconError {
15
+ }
16
+ export class InternalError extends DefconError {
17
+ }
@@ -1,3 +1,4 @@
1
+ import { NotFoundError } from "../errors.js";
1
2
  import { consoleLogger } from "../logger.js";
2
3
  const DEFAULT_MODEL_TIER_MAP = {
3
4
  opus: "claude-opus-4-6",
@@ -29,7 +30,7 @@ export class ActiveRunner {
29
30
  if (flowName) {
30
31
  const flow = await this.flowRepo.getByName(flowName);
31
32
  if (!flow)
32
- throw new Error(`Flow "${flowName}" not found`);
33
+ throw new NotFoundError(`Flow "${flowName}" not found`);
33
34
  flowId = flow.id;
34
35
  }
35
36
  while (true) {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, timingSafeEqual } from "node:crypto";
3
- import { writeFileSync } from "node:fs";
3
+ import { readdirSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
@@ -55,7 +55,22 @@ export function validateWorkerToken(opts) {
55
55
  "Set DEFCON_WORKER_TOKEN in your environment or use stdio transport for local-only access.");
56
56
  }
57
57
  }
58
- const MIGRATIONS_FOLDER = new URL("../../drizzle", import.meta.url).pathname;
58
+ // Resolve drizzle migrations folder. Works for both tsx (src/) and compiled (dist/src/) contexts.
59
+ const MIGRATIONS_FOLDER = (() => {
60
+ const candidates = ["../../drizzle", "../../../drizzle"].map((rel) => new URL(rel, import.meta.url).pathname);
61
+ const found = candidates.find((p) => {
62
+ try {
63
+ readdirSync(p);
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ });
70
+ if (!found)
71
+ throw new Error(`Cannot find drizzle migrations folder (tried: ${candidates.join(", ")})`);
72
+ return found;
73
+ })();
59
74
  const REAPER_INTERVAL_DEFAULT = "30000"; // 30s
60
75
  const CLAIM_TTL_DEFAULT = "300000"; // 5min
61
76
  function openDb(dbPath) {
@@ -433,7 +433,7 @@ function errorResult(message) {
433
433
  isError: true,
434
434
  };
435
435
  }
436
- const RETRY_SHORT_MS = 30_000; // entities exist but all claimed
436
+ const RETRY_SHORT_MS = 30_000; // work exists but all entities currently claimed
437
437
  const RETRY_LONG_MS = 300_000; // backlog empty
438
438
  function noWorkResult(retryAfterMs, role) {
439
439
  return jsonResult({
@@ -448,152 +448,41 @@ function constantTimeEqual(a, b) {
448
448
  return timingSafeEqual(hashA, hashB);
449
449
  }
450
450
  // ─── Tool Handlers ───
451
- const AFFINITY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
452
451
  async function handleFlowClaim(deps, args) {
453
452
  const v = validateInput(FlowClaimSchema, args);
454
453
  if (!v.ok)
455
454
  return v.result;
456
- const log = deps.logger ?? consoleLogger;
457
455
  const { worker_id, role, flow: flowName } = v.data;
458
- // 1. Find candidate flows filtered by discipline
459
- let candidateFlows = [];
460
- if (flowName) {
461
- const flow = await deps.flows.getByName(flowName);
462
- if (!flow)
463
- return errorResult(`Flow not found: ${flowName}`);
464
- // Discipline must match — null discipline flows are claimable by any role
465
- if (flow.discipline !== null && flow.discipline !== role)
466
- return noWorkResult(RETRY_LONG_MS, role);
467
- candidateFlows = [flow];
468
- }
469
- else {
470
- const allFlows = await deps.flows.list();
471
- candidateFlows = allFlows.filter((f) => f.discipline === null || f.discipline === role);
456
+ if (!deps.engine) {
457
+ return errorResult("Engine not available — MCP server started without engine dependency");
472
458
  }
473
- if (candidateFlows.length === 0)
474
- return noWorkResult(RETRY_LONG_MS, role);
475
- const allCandidates = [];
476
- for (const flow of candidateFlows) {
477
- const unclaimed = await deps.invocations.findUnclaimedByFlow(flow.id);
478
- allCandidates.push(...unclaimed);
459
+ let result;
460
+ try {
461
+ result = await deps.engine.claimWork(role, flowName, worker_id);
479
462
  }
480
- if (allCandidates.length === 0) {
481
- // Determine if entities exist but are all claimed (short retry) vs empty backlog (long retry).
482
- // Use hasAnyInFlowAndState (SELECT 1 LIMIT 1) to avoid loading full entity rows across all states.
483
- let hasEntities = false;
484
- for (const flow of candidateFlows) {
485
- const stateNames = flow.states.filter((s) => s.promptTemplate !== null).map((s) => s.name);
486
- if (await deps.entities.hasAnyInFlowAndState(flow.id, stateNames)) {
487
- hasEntities = true;
488
- break;
489
- }
463
+ catch (err) {
464
+ const message = err instanceof Error ? err.message : String(err);
465
+ const isNotFound = /not found|unknown flow/i.test(message);
466
+ if (isNotFound) {
467
+ return errorResult(message);
490
468
  }
491
- return noWorkResult(hasEntities ? RETRY_SHORT_MS : RETRY_LONG_MS, role);
469
+ return jsonResult({ next_action: "check_back", retry_after_ms: RETRY_LONG_MS, message });
492
470
  }
493
- // 3. Load entities for priority sorting
494
- const entityMap = new Map();
495
- const uniqueEntityIds = [...new Set(allCandidates.map((inv) => inv.entityId))];
496
- await Promise.all(uniqueEntityIds.map(async (eid) => {
497
- const entity = await deps.entities.get(eid);
498
- if (entity)
499
- entityMap.set(eid, entity);
500
- }));
501
- // 4. Check affinity for each entity
502
- const flowByIdEarly = new Map(candidateFlows.map((f) => [f.id, f]));
503
- const affinitySet = new Set();
504
- const now = Date.now();
505
- if (worker_id) {
506
- await Promise.all(uniqueEntityIds.map(async (eid) => {
507
- const entity = entityMap.get(eid);
508
- const flow = entity ? flowByIdEarly.get(entity.flowId) : undefined;
509
- const windowMs = flow?.affinityWindowMs ?? AFFINITY_WINDOW_MS;
510
- const invocations = await deps.invocations.findByEntity(eid);
511
- const lastCompleted = invocations
512
- .filter((inv) => inv.completedAt !== null && inv.claimedBy === worker_id)
513
- .sort((a, b) => (b.completedAt?.getTime() ?? 0) - (a.completedAt?.getTime() ?? 0));
514
- if (lastCompleted.length > 0) {
515
- const elapsed = now - (lastCompleted[0].completedAt?.getTime() ?? 0);
516
- if (elapsed < windowMs) {
517
- affinitySet.add(eid);
518
- }
519
- }
520
- }));
471
+ if (result === "all_claimed") {
472
+ return noWorkResult(RETRY_SHORT_MS, role);
521
473
  }
522
- // 5. Sort candidates by priority algorithm
523
- allCandidates.sort((a, b) => {
524
- const entityA = entityMap.get(a.entityId);
525
- const entityB = entityMap.get(b.entityId);
526
- // Tier 1: Affinity (has affinity sorts first)
527
- const affinityA = affinitySet.has(a.entityId) ? 1 : 0;
528
- const affinityB = affinitySet.has(b.entityId) ? 1 : 0;
529
- if (affinityA !== affinityB)
530
- return affinityB - affinityA;
531
- // Tier 2: Entity priority (higher priority sorts first)
532
- const priA = entityA?.priority ?? 0;
533
- const priB = entityB?.priority ?? 0;
534
- if (priA !== priB)
535
- return priB - priA;
536
- // Tier 3: Time in state (longest waiting sorts first — earlier createdAt as stable proxy)
537
- const timeA = entityA?.createdAt?.getTime() ?? now;
538
- const timeB = entityB?.createdAt?.getTime() ?? now;
539
- return timeA - timeB;
540
- });
541
- // 6. Build a flow lookup map
542
- const flowById = new Map(candidateFlows.map((f) => [f.id, f]));
543
- // 7. Try claiming in priority order (handle race conditions)
544
- for (const invocation of allCandidates) {
545
- let claimed;
546
- try {
547
- claimed = await deps.invocations.claim(invocation.id, worker_id ?? `agent:${role}`);
548
- }
549
- catch (err) {
550
- log.error(`Failed to claim invocation ${invocation.id}:`, err);
551
- continue;
552
- }
553
- if (claimed) {
554
- const entity = entityMap.get(claimed.entityId);
555
- if (entity) {
556
- let claimedEntity;
557
- try {
558
- claimedEntity = await deps.entities.claimById(entity.id, worker_id ?? `agent:${role}`);
559
- }
560
- catch (err) {
561
- log.error(`Failed to claimById for entity ${entity.id}:`, err);
562
- // Release the invocation claim so it can be reclaimed rather than orphaned.
563
- await deps.invocations.releaseClaim(claimed.id);
564
- continue;
565
- }
566
- if (!claimedEntity) {
567
- // Race condition: another worker claimed this entity first.
568
- // Release the invocation claim so it can be picked up by another worker.
569
- await deps.invocations.releaseClaim(claimed.id);
570
- continue;
571
- }
572
- }
573
- const flow = entity ? flowById.get(entity.flowId) : undefined;
574
- // Record affinity for the claiming worker
575
- if (worker_id && entity && flow) {
576
- const windowMs = flow.affinityWindowMs ?? 300000;
577
- try {
578
- await deps.entities.setAffinity(claimed.entityId, worker_id, role, new Date(Date.now() + windowMs));
579
- }
580
- catch (err) {
581
- // Affinity is non-critical — log and continue with the successful claim.
582
- log.error(`Failed to set affinity for entity ${claimed.entityId} worker ${worker_id}:`, err);
583
- }
584
- }
585
- return jsonResult({
586
- worker_id: worker_id,
587
- entity_id: claimed.entityId,
588
- invocation_id: claimed.id,
589
- flow: flow?.name ?? null,
590
- stage: claimed.stage,
591
- prompt: claimed.prompt,
592
- context: claimed.context,
593
- });
594
- }
474
+ if (!result) {
475
+ return noWorkResult(RETRY_LONG_MS, role);
595
476
  }
596
- return noWorkResult(RETRY_SHORT_MS, role);
477
+ return jsonResult({
478
+ worker_id: worker_id,
479
+ entity_id: result.entityId,
480
+ invocation_id: result.invocationId,
481
+ flow: result.flowName,
482
+ stage: result.stage,
483
+ prompt: result.prompt,
484
+ context: result.context,
485
+ });
597
486
  }
598
487
  async function handleFlowGetPrompt(deps, args) {
599
488
  const v = validateInput(FlowGetPromptSchema, args);
@@ -12,3 +12,4 @@ export declare function bootstrap(dbPath?: string): {
12
12
  };
13
13
  export * from "./api/wire-types.js";
14
14
  export * from "./engine/index.js";
15
+ export { ConflictError, DefconError, GateError, InternalError, NotFoundError, ValidationError } from "./errors.js";
package/dist/src/main.js CHANGED
@@ -26,3 +26,4 @@ export function bootstrap(dbPath = DB_PATH) {
26
26
  }
27
27
  export * from "./api/wire-types.js";
28
28
  export * from "./engine/index.js";
29
+ export { ConflictError, DefconError, GateError, InternalError, NotFoundError, ValidationError } from "./errors.js";
@@ -1,4 +1,5 @@
1
1
  import { and, eq, inArray, isNull, lt, not } from "drizzle-orm";
2
+ import { NotFoundError } from "../../errors.js";
2
3
  import { entities } from "./schema.js";
3
4
  export class DrizzleEntityRepository {
4
5
  db;
@@ -70,7 +71,7 @@ export class DrizzleEntityRepository {
70
71
  return this.db.transaction((tx) => {
71
72
  const rows = tx.select().from(entities).where(eq(entities.id, id)).limit(1).all();
72
73
  if (rows.length === 0)
73
- throw new Error(`Entity not found: ${id}`);
74
+ throw new NotFoundError(`Entity not found: ${id}`);
74
75
  const row = rows[0];
75
76
  const now = Date.now();
76
77
  const mergedArtifacts = artifacts
@@ -91,7 +92,7 @@ export class DrizzleEntityRepository {
91
92
  async updateArtifacts(id, artifacts) {
92
93
  const rows = await this.db.select().from(entities).where(eq(entities.id, id)).limit(1);
93
94
  if (rows.length === 0)
94
- throw new Error(`Entity not found: ${id}`);
95
+ throw new NotFoundError(`Entity not found: ${id}`);
95
96
  const existing = rows[0].artifacts ?? {};
96
97
  await this.db
97
98
  .update(entities)
@@ -145,7 +146,7 @@ export class DrizzleEntityRepository {
145
146
  this.db.transaction((tx) => {
146
147
  const rows = tx.select().from(entities).where(eq(entities.id, parentId)).limit(1).all();
147
148
  if (rows.length === 0)
148
- throw new Error(`Entity ${parentId} not found`);
149
+ throw new NotFoundError(`Entity ${parentId} not found`);
149
150
  const row = rows[0];
150
151
  const artifacts = row.artifacts ?? {};
151
152
  const existing = (Array.isArray(artifacts.spawnedChildren) ? artifacts.spawnedChildren : []);
@@ -1,4 +1,5 @@
1
1
  import { and, eq } from "drizzle-orm";
2
+ import { NotFoundError } from "../../errors.js";
2
3
  import { flowDefinitions, flowVersions, stateDefinitions, transitionRules } from "./schema.js";
3
4
  function toDate(v) {
4
5
  return v != null ? new Date(v) : null;
@@ -119,7 +120,7 @@ export class DrizzleFlowRepository {
119
120
  const now = Date.now();
120
121
  const current = this.db.select().from(flowDefinitions).where(eq(flowDefinitions.id, id)).all();
121
122
  if (current.length === 0)
122
- throw new Error(`Flow not found: ${id}`);
123
+ throw new NotFoundError(`Flow not found: ${id}`);
123
124
  const updateValues = { updatedAt: now };
124
125
  if (changes.name !== undefined)
125
126
  updateValues.name = changes.name;
@@ -173,7 +174,7 @@ export class DrizzleFlowRepository {
173
174
  async updateState(stateId, changes) {
174
175
  const existing = this.db.select().from(stateDefinitions).where(eq(stateDefinitions.id, stateId)).all();
175
176
  if (existing.length === 0)
176
- throw new Error(`State not found: ${stateId}`);
177
+ throw new NotFoundError(`State not found: ${stateId}`);
177
178
  const updateValues = {};
178
179
  if (changes.name !== undefined)
179
180
  updateValues.name = changes.name;
@@ -219,7 +220,7 @@ export class DrizzleFlowRepository {
219
220
  async updateTransition(transitionId, changes) {
220
221
  const existing = this.db.select().from(transitionRules).where(eq(transitionRules.id, transitionId)).all();
221
222
  if (existing.length === 0)
222
- throw new Error(`Transition not found: ${transitionId}`);
223
+ throw new NotFoundError(`Transition not found: ${transitionId}`);
223
224
  const updateValues = {};
224
225
  if (changes.fromState !== undefined)
225
226
  updateValues.fromState = changes.fromState;
@@ -247,7 +248,7 @@ export class DrizzleFlowRepository {
247
248
  async snapshot(flowId) {
248
249
  const flow = await this.get(flowId);
249
250
  if (!flow)
250
- throw new Error(`Flow not found: ${flowId}`);
251
+ throw new NotFoundError(`Flow not found: ${flowId}`);
251
252
  const now = Date.now();
252
253
  const id = crypto.randomUUID();
253
254
  const snapshotData = {
@@ -304,7 +305,7 @@ export class DrizzleFlowRepository {
304
305
  .where(and(eq(flowVersions.flowId, flowId), eq(flowVersions.version, version)))
305
306
  .all();
306
307
  if (versionRows.length === 0)
307
- throw new Error(`Version ${version} not found for flow ${flowId}`);
308
+ throw new NotFoundError(`Version ${version} not found for flow ${flowId}`);
308
309
  const snap = versionRows[0].snapshot;
309
310
  this.db.transaction((tx) => {
310
311
  tx.delete(transitionRules).where(eq(transitionRules.flowId, flowId)).run();
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { asc, eq, sql } from "drizzle-orm";
3
+ import { InternalError } from "../../errors.js";
3
4
  import { gateDefinitions, gateResults } from "./schema.js";
4
5
  function toGate(row) {
5
6
  return {
@@ -45,7 +46,7 @@ export class DrizzleGateRepository {
45
46
  this.db.insert(gateDefinitions).values(values).run();
46
47
  const row = this.db.select().from(gateDefinitions).where(eq(gateDefinitions.id, id)).get();
47
48
  if (!row)
48
- throw new Error(`Gate ${id} not found after insert`);
49
+ throw new InternalError(`Gate ${id} not found after insert`);
49
50
  return toGate(row);
50
51
  }
51
52
  async get(id) {
@@ -76,14 +77,14 @@ export class DrizzleGateRepository {
76
77
  .run();
77
78
  const row = this.db.select().from(gateResults).where(eq(gateResults.id, id)).get();
78
79
  if (!row)
79
- throw new Error(`GateResult ${id} not found after insert`);
80
+ throw new InternalError(`GateResult ${id} not found after insert`);
80
81
  return toGateResult(row);
81
82
  }
82
83
  async update(id, changes) {
83
84
  this.db.update(gateDefinitions).set(changes).where(eq(gateDefinitions.id, id)).run();
84
85
  const row = this.db.select().from(gateDefinitions).where(eq(gateDefinitions.id, id)).get();
85
86
  if (!row)
86
- throw new Error(`Gate ${id} not found after update`);
87
+ throw new InternalError(`Gate ${id} not found after update`);
87
88
  return toGate(row);
88
89
  }
89
90
  async resultsFor(entityId) {
@@ -1,4 +1,5 @@
1
1
  import { and, asc, eq, isNotNull, isNull, sql } from "drizzle-orm";
2
+ import { ConflictError, InternalError, NotFoundError } from "../../errors.js";
2
3
  import { entities, invocations } from "./schema.js";
3
4
  function toInvocation(row) {
4
5
  return {
@@ -41,7 +42,7 @@ export class DrizzleInvocationRepository {
41
42
  .run();
42
43
  const created = await this.get(id);
43
44
  if (!created)
44
- throw new Error(`Invocation ${id} not found after insert`);
45
+ throw new InternalError(`Invocation ${id} not found after insert`);
45
46
  return created;
46
47
  }
47
48
  async get(id) {
@@ -68,16 +69,16 @@ export class DrizzleInvocationRepository {
68
69
  if (result.changes === 0) {
69
70
  const existing = this.db.select().from(invocations).where(eq(invocations.id, id)).all();
70
71
  if (existing.length === 0)
71
- throw new Error(`Invocation ${id} not found`);
72
+ throw new NotFoundError(`Invocation ${id} not found`);
72
73
  if (existing[0].completedAt)
73
- throw new Error(`Invocation ${id} already completed`);
74
+ throw new ConflictError(`Invocation ${id} already completed`);
74
75
  if (existing[0].failedAt)
75
- throw new Error(`Invocation ${id} already failed`);
76
- throw new Error(`Invocation ${id} concurrent modification detected`);
76
+ throw new ConflictError(`Invocation ${id} already failed`);
77
+ throw new ConflictError(`Invocation ${id} concurrent modification detected`);
77
78
  }
78
79
  const row = this.db.select().from(invocations).where(eq(invocations.id, id)).all();
79
80
  if (row.length === 0)
80
- throw new Error(`Invocation ${id} not found after update`);
81
+ throw new InternalError(`Invocation ${id} not found after update`);
81
82
  return toInvocation(row[0]);
82
83
  }
83
84
  async fail(id, error) {
@@ -89,16 +90,16 @@ export class DrizzleInvocationRepository {
89
90
  if (result.changes === 0) {
90
91
  const existing = this.db.select().from(invocations).where(eq(invocations.id, id)).all();
91
92
  if (existing.length === 0)
92
- throw new Error(`Invocation ${id} not found`);
93
+ throw new NotFoundError(`Invocation ${id} not found`);
93
94
  if (existing[0].completedAt)
94
- throw new Error(`Invocation ${id} already completed`);
95
+ throw new ConflictError(`Invocation ${id} already completed`);
95
96
  if (existing[0].failedAt)
96
- throw new Error(`Invocation ${id} already failed`);
97
- throw new Error(`Invocation ${id} concurrent modification detected`);
97
+ throw new ConflictError(`Invocation ${id} already failed`);
98
+ throw new ConflictError(`Invocation ${id} concurrent modification detected`);
98
99
  }
99
100
  const row = this.db.select().from(invocations).where(eq(invocations.id, id)).all();
100
101
  if (row.length === 0)
101
- throw new Error(`Invocation ${id} not found after update`);
102
+ throw new InternalError(`Invocation ${id} not found after update`);
102
103
  return toInvocation(row[0]);
103
104
  }
104
105
  async releaseClaim(id) {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@wopr-network/defcon",
3
- "version": "0.2.2",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
+ "packageManager": "pnpm@9.15.4",
5
6
  "engines": {
6
7
  "node": ">=24.0.0"
7
8
  },
8
- "packageManager": "pnpm@9.15.4",
9
9
  "main": "./dist/src/main.js",
10
10
  "bin": {
11
11
  "defcon": "./dist/src/execution/cli.js"
@@ -61,5 +61,15 @@
61
61
  "onlyBuiltDependencies": [
62
62
  "better-sqlite3"
63
63
  ]
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ },
68
+ "release": {
69
+ "extends": "@wopr-network/semantic-release-config"
70
+ },
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "https://github.com/wopr-network/defcon.git"
64
74
  }
65
75
  }