@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.
- package/dist/src/engine/engine.d.ts +4 -10
- package/dist/src/engine/engine.js +212 -77
- package/dist/src/engine/flow-spawner.js +2 -1
- package/dist/src/engine/gate-command-validator.d.ts +1 -0
- package/dist/src/engine/gate-command-validator.js +23 -5
- package/dist/src/engine/gate-evaluator.js +9 -6
- package/dist/src/engine/handlebars.js +11 -1
- package/dist/src/engine/shell-words.d.ts +9 -0
- package/dist/src/engine/shell-words.js +74 -0
- package/dist/src/errors.d.ts +13 -0
- package/dist/src/errors.js +17 -0
- package/dist/src/execution/active-runner.js +2 -1
- package/dist/src/execution/cli.js +17 -2
- package/dist/src/execution/mcp-server.js +25 -136
- package/dist/src/main.d.ts +1 -0
- package/dist/src/main.js +1 -0
- package/dist/src/repositories/drizzle/entity.repo.js +4 -3
- package/dist/src/repositories/drizzle/flow.repo.js +6 -5
- package/dist/src/repositories/drizzle/gate.repo.js +4 -3
- package/dist/src/repositories/drizzle/invocation.repo.js +12 -11
- package/package.json +12 -2
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
//
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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.
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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
|
|
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, {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
54
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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; //
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
469
|
+
return jsonResult({ next_action: "check_back", retry_after_ms: RETRY_LONG_MS, message });
|
|
492
470
|
}
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
523
|
-
|
|
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
|
|
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);
|
package/dist/src/main.d.ts
CHANGED
package/dist/src/main.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
72
|
+
throw new NotFoundError(`Invocation ${id} not found`);
|
|
72
73
|
if (existing[0].completedAt)
|
|
73
|
-
throw new
|
|
74
|
+
throw new ConflictError(`Invocation ${id} already completed`);
|
|
74
75
|
if (existing[0].failedAt)
|
|
75
|
-
throw new
|
|
76
|
-
throw new
|
|
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
|
|
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
|
|
93
|
+
throw new NotFoundError(`Invocation ${id} not found`);
|
|
93
94
|
if (existing[0].completedAt)
|
|
94
|
-
throw new
|
|
95
|
+
throw new ConflictError(`Invocation ${id} already completed`);
|
|
95
96
|
if (existing[0].failedAt)
|
|
96
|
-
throw new
|
|
97
|
-
throw new
|
|
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
|
|
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.
|
|
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
|
}
|