@wopr-network/defcon 1.6.1 → 1.8.0

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.
@@ -28,6 +28,7 @@ export interface EngineStatus {
28
28
  flows: Record<string, Record<string, number>>;
29
29
  activeInvocations: number;
30
30
  pendingClaims: number;
31
+ versionDistribution: Record<string, Record<string, number>>;
31
32
  }
32
33
  export interface EngineDeps {
33
34
  entityRepo: IEntityRepository;
@@ -72,10 +72,13 @@ export class Engine {
72
72
  const entity = await this.entityRepo.get(entityId);
73
73
  if (!entity)
74
74
  throw new NotFoundError(`Entity "${entityId}" not found`);
75
- // 2. Load flow
76
- const flow = await this.flowRepo.get(entity.flowId);
75
+ if (entity.state === "cancelled") {
76
+ throw new ValidationError(`Entity "${entityId}" is cancelled and cannot be transitioned`);
77
+ }
78
+ // 2. Load flow at entity's pinned version
79
+ const flow = await this.flowRepo.getAtVersion(entity.flowId, entity.flowVersion);
77
80
  if (!flow)
78
- throw new NotFoundError(`Flow "${entity.flowId}" not found`);
81
+ throw new NotFoundError(`Flow "${entity.flowId}" version ${entity.flowVersion} not found`);
79
82
  // 3. Find transition
80
83
  const transition = findTransition(flow, entity.state, signal, { entity }, true, this.logger);
81
84
  if (!transition)
@@ -325,7 +328,7 @@ export class Engine {
325
328
  const flow = await this.flowRepo.getByName(flowName);
326
329
  if (!flow)
327
330
  throw new NotFoundError(`Flow "${flowName}" not found`);
328
- let entity = await this.entityRepo.create(flow.id, flow.initialState, refs);
331
+ let entity = await this.entityRepo.create(flow.id, flow.initialState, refs, flow.version);
329
332
  // Store any caller-supplied payload as initial artifacts so prompt templates
330
333
  // can access refs like {{entity.artifacts.refs.linear.id}}.
331
334
  if (payload && Object.keys(payload).length > 0) {
@@ -466,6 +469,8 @@ export class Engine {
466
469
  const entity = entityMap.get(pending.entityId);
467
470
  if (!entity)
468
471
  continue;
472
+ if (entity.state === "cancelled")
473
+ continue;
469
474
  // Guard: entity state must still match the invocation's stage — if another worker
470
475
  // transitioned the entity between candidate fetch and now, skip this candidate.
471
476
  if (entity.state !== pending.stage)
@@ -516,9 +521,11 @@ export class Engine {
516
521
  this.logger.warn(`[engine] setAffinity failed for entity ${claimed.id} worker ${worker_id} — continuing:`, err);
517
522
  }
518
523
  }
519
- const state = flow.states.find((s) => s.name === pending.stage);
524
+ const versionedFlow = await this.flowRepo.getAtVersion(claimed.flowId, claimed.flowVersion);
525
+ const effectiveFlow = versionedFlow ?? flow;
526
+ const state = effectiveFlow.states.find((s) => s.name === pending.stage);
520
527
  const build = state
521
- ? await this.buildPromptForEntity(state, claimed, flow)
528
+ ? await this.buildPromptForEntity(state, claimed, effectiveFlow)
522
529
  : { prompt: pending.prompt, context: null };
523
530
  await this.eventEmitter.emit({
524
531
  type: "entity.claimed",
@@ -543,12 +550,18 @@ export class Engine {
543
550
  const claimed = await this.entityRepo.claim(flow.id, state.name, worker_id ?? `agent:${role}`);
544
551
  if (!claimed)
545
552
  continue;
546
- const canCreate = await this.checkConcurrency(flow, claimed);
553
+ if (claimed.state === "cancelled") {
554
+ await this.entityRepo.release(claimed.id, worker_id ?? `agent:${role}`);
555
+ continue;
556
+ }
557
+ const claimedVersionedFlow = await this.flowRepo.getAtVersion(claimed.flowId, claimed.flowVersion);
558
+ const claimedEffectiveFlow = claimedVersionedFlow ?? flow;
559
+ const canCreate = await this.checkConcurrency(claimedEffectiveFlow, claimed);
547
560
  if (!canCreate) {
548
561
  await this.entityRepo.release(claimed.id, worker_id ?? `agent:${role}`);
549
562
  continue;
550
563
  }
551
- const build = await this.buildPromptForEntity(state, claimed, flow);
564
+ const build = await this.buildPromptForEntity(state, claimed, claimedEffectiveFlow);
552
565
  const invocation = await this.invocationRepo.create(claimed.id, state.name, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
553
566
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
554
567
  : undefined, state.agentRole ?? null);
@@ -647,7 +660,21 @@ export class Engine {
647
660
  activeInvocations += active;
648
661
  pendingClaims += pending;
649
662
  }
650
- return { flows: statusData, activeInvocations, pendingClaims };
663
+ const versionDistribution = {};
664
+ for (const flow of allFlows) {
665
+ const versionCounts = {};
666
+ for (const state of flow.states) {
667
+ const stateEntities = await this.entityRepo.findByFlowAndState(flow.id, state.name);
668
+ for (const e of stateEntities) {
669
+ const vKey = String(e.flowVersion);
670
+ versionCounts[vKey] = (versionCounts[vKey] ?? 0) + 1;
671
+ }
672
+ }
673
+ if (Object.keys(versionCounts).length > 0) {
674
+ versionDistribution[flow.id] = versionCounts;
675
+ }
676
+ }
677
+ return { flows: statusData, activeInvocations, pendingClaims, versionDistribution };
651
678
  }
652
679
  startReaper(intervalMs, entityTtlMs = 60_000) {
653
680
  let tickInFlight = false;
@@ -24,6 +24,14 @@ export type EngineEvent = {
24
24
  entityId: string;
25
25
  flowId: string;
26
26
  emittedAt: Date;
27
+ } | {
28
+ type: "entity.cancelled";
29
+ entityId: string;
30
+ flowId: string;
31
+ cancelledBy: string;
32
+ reason: string | null;
33
+ cascade: boolean;
34
+ emittedAt: Date;
27
35
  } | {
28
36
  type: "invocation.created";
29
37
  entityId: string;
@@ -11,7 +11,7 @@ export async function executeSpawn(transition, parentEntity, flowRepo, entityRep
11
11
  const flow = await flowRepo.getByName(transition.spawnFlow);
12
12
  if (!flow)
13
13
  throw new NotFoundError(`Spawn flow "${transition.spawnFlow}" not found`);
14
- const childEntity = await entityRepo.create(flow.id, flow.initialState, parentEntity.refs ?? undefined);
14
+ const childEntity = await entityRepo.create(flow.id, flow.initialState, parentEntity.refs ?? undefined, undefined, parentEntity.id);
15
15
  try {
16
16
  await entityRepo.appendSpawnedChild(parentEntity.id, {
17
17
  childId: childEntity.id,
@@ -119,11 +119,16 @@ export declare const AdminFlowPauseSchema: z.ZodObject<{
119
119
  }, z.core.$strip>;
120
120
  export declare const AdminEntityCancelSchema: z.ZodObject<{
121
121
  entity_id: z.ZodString;
122
+ cascade: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
123
+ reason: z.ZodOptional<z.ZodString>;
122
124
  }, z.core.$strip>;
123
125
  export declare const AdminEntityResetSchema: z.ZodObject<{
124
126
  entity_id: z.ZodString;
125
127
  target_state: z.ZodString;
126
128
  }, z.core.$strip>;
129
+ export declare const AdminEntityMigrateSchema: z.ZodObject<{
130
+ entity_id: z.ZodString;
131
+ }, z.core.$strip>;
127
132
  export declare const AdminWorkerDrainSchema: z.ZodObject<{
128
133
  worker_id: z.ZodString;
129
134
  }, z.core.$strip>;
@@ -128,11 +128,16 @@ export const AdminFlowPauseSchema = z.object({
128
128
  });
129
129
  export const AdminEntityCancelSchema = z.object({
130
130
  entity_id: z.string().min(1),
131
+ cascade: z.boolean().optional().default(false),
132
+ reason: z.string().optional(),
131
133
  });
132
134
  export const AdminEntityResetSchema = z.object({
133
135
  entity_id: z.string().min(1),
134
136
  target_state: z.string().min(1),
135
137
  });
138
+ export const AdminEntityMigrateSchema = z.object({
139
+ entity_id: z.string().min(1),
140
+ });
136
141
  export const AdminWorkerDrainSchema = z.object({
137
142
  worker_id: z.string().min(1),
138
143
  });
@@ -184,6 +184,7 @@ program
184
184
  transitions: transitionLogRepo,
185
185
  eventRepo: new DrizzleEventRepository(db),
186
186
  engine,
187
+ withTransaction: (fn) => withTransaction(sqlite, fn),
187
188
  };
188
189
  const reaperInterval = parseInt(opts.reaperInterval, 10);
189
190
  if (Number.isNaN(reaperInterval) || reaperInterval < 1000) {
@@ -11,6 +11,7 @@ export interface McpServerDeps {
11
11
  eventRepo: IEventRepository;
12
12
  engine?: Engine;
13
13
  logger?: Logger;
14
+ withTransaction?: <T>(fn: () => T | Promise<T>) => Promise<T>;
14
15
  }
15
16
  export interface McpServerOpts {
16
17
  /** DEFCON_ADMIN_TOKEN — if set, admin.* tools require this token */
@@ -6,7 +6,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
6
6
  import { DEFAULT_TIMEOUT_PROMPT } from "../engine/constants.js";
7
7
  import { isTerminal } from "../engine/state-machine.js";
8
8
  import { consoleLogger } from "../logger.js";
9
- import { AdminEntityCancelSchema, AdminEntityResetSchema, AdminFlowCreateSchema, AdminFlowPauseSchema, AdminFlowRestoreSchema, AdminFlowSnapshotSchema, AdminFlowUpdateSchema, AdminGateAttachSchema, AdminGateCreateSchema, AdminGateRerunSchema, AdminStateCreateSchema, AdminStateUpdateSchema, AdminTransitionCreateSchema, AdminTransitionUpdateSchema, AdminWorkerDrainSchema, } from "./admin-schemas.js";
9
+ import { AdminEntityCancelSchema, AdminEntityMigrateSchema, AdminEntityResetSchema, AdminFlowCreateSchema, AdminFlowPauseSchema, AdminFlowRestoreSchema, AdminFlowSnapshotSchema, AdminFlowUpdateSchema, AdminGateAttachSchema, AdminGateCreateSchema, AdminGateRerunSchema, AdminStateCreateSchema, AdminStateUpdateSchema, AdminTransitionCreateSchema, AdminTransitionUpdateSchema, AdminWorkerDrainSchema, } from "./admin-schemas.js";
10
10
  import { handleFlowClaim } from "./handlers/flow.js";
11
11
  import { FlowFailSchema, FlowGetPromptSchema, FlowReportSchema, FlowSeedSchema, QueryEntitiesSchema, QueryEntitySchema, QueryFlowSchema, QueryInvocationsSchema, } from "./tool-schemas.js";
12
12
  function getSystemDefaultGateTimeoutMs() {
@@ -350,8 +350,16 @@ const TOOL_DEFINITIONS = [
350
350
  },
351
351
  {
352
352
  name: "admin.entity.cancel",
353
- description: "Cancel an entity — fails active invocation, moves to 'cancelled' terminal state.",
354
- inputSchema: { type: "object", properties: { entity_id: { type: "string" } }, required: ["entity_id"] },
353
+ description: "Cancel an entity — fails active invocation, moves to 'cancelled' terminal state. Use cascade to cancel all descendant entities.",
354
+ inputSchema: {
355
+ type: "object",
356
+ properties: {
357
+ entity_id: { type: "string" },
358
+ cascade: { type: "boolean", description: "Recursively cancel all child entities" },
359
+ reason: { type: "string", description: "Cancellation reason" },
360
+ },
361
+ required: ["entity_id"],
362
+ },
355
363
  },
356
364
  {
357
365
  name: "admin.entity.reset",
@@ -362,6 +370,11 @@ const TOOL_DEFINITIONS = [
362
370
  required: ["entity_id", "target_state"],
363
371
  },
364
372
  },
373
+ {
374
+ name: "admin.entity.migrate",
375
+ description: "Migrate an entity to the latest flow version. Validates that the entity's current state exists in the new version.",
376
+ inputSchema: { type: "object", properties: { entity_id: { type: "string" } }, required: ["entity_id"] },
377
+ },
365
378
  {
366
379
  name: "admin.worker.drain",
367
380
  description: "Mark a worker as draining — claimWork() will skip it.",
@@ -465,6 +478,8 @@ export async function callToolHandler(deps, name, safeArgs, opts) {
465
478
  return await handleAdminEntityCancel(deps, safeArgs);
466
479
  case "admin.entity.reset":
467
480
  return await handleAdminEntityReset(deps, safeArgs);
481
+ case "admin.entity.migrate":
482
+ return await handleAdminEntityMigrate(deps, safeArgs);
468
483
  case "admin.worker.drain":
469
484
  return await handleAdminWorkerDrain(deps, safeArgs);
470
485
  case "admin.worker.undrain":
@@ -748,31 +763,67 @@ async function handleAdminEntityCancel(deps, args) {
748
763
  const v = validateInput(AdminEntityCancelSchema, args);
749
764
  if (!v.ok)
750
765
  return v.result;
751
- const entity = await deps.entities.get(v.data.entity_id);
766
+ const { entity_id, cascade, reason } = v.data;
767
+ const entity = await deps.entities.get(entity_id);
752
768
  if (!entity)
753
- return errorResult(`Entity not found: ${v.data.entity_id}`);
754
- const flow = await deps.flows.get(entity.flowId);
755
- if (!flow)
756
- return errorResult(`Flow not found for entity: ${v.data.entity_id}`);
757
- const cancelledState = flow.states.find((s) => s.name === "cancelled");
758
- if (!cancelledState)
759
- return errorResult(`State 'cancelled' not found in flow '${flow.name}'`);
760
- const invocations = await deps.invocations.findByEntity(v.data.entity_id);
761
- for (const inv of invocations) {
762
- if (inv.completedAt === null && inv.failedAt === null) {
763
- await deps.invocations.fail(inv.id, "Cancelled by admin");
769
+ return errorResult(`Entity not found: ${entity_id}`);
770
+ if (entity.state === "cancelled" && !cascade)
771
+ return errorResult(`Entity ${entity_id} is already cancelled`);
772
+ const cancelled = [];
773
+ const MAX_CANCEL_DEPTH = 100;
774
+ async function cancelOne(eid, depth, visited) {
775
+ if (visited.has(eid))
776
+ return;
777
+ visited.add(eid);
778
+ if (depth > MAX_CANCEL_DEPTH) {
779
+ throw new Error(`cancelOne exceeded maximum recursion depth of ${MAX_CANCEL_DEPTH} — possible cycle or pathologically deep entity tree`);
780
+ }
781
+ const e = await deps.entities.get(eid);
782
+ const alreadyCancelled = !e || e.state === "cancelled";
783
+ if (!alreadyCancelled) {
784
+ const invocations = await deps.invocations.findByEntity(eid);
785
+ for (const inv of invocations) {
786
+ if (inv.completedAt === null && inv.failedAt === null) {
787
+ await deps.invocations.fail(inv.id, reason ?? "Cancelled by admin");
788
+ }
789
+ }
790
+ await deps.entities.cancelEntity(eid);
791
+ await deps.transitions.record({
792
+ entityId: eid,
793
+ fromState: e.state,
794
+ toState: "cancelled",
795
+ trigger: "admin.cancel",
796
+ invocationId: null,
797
+ timestamp: new Date(),
798
+ });
799
+ if (deps.engine) {
800
+ await deps.engine.emit({
801
+ type: "entity.cancelled",
802
+ entityId: eid,
803
+ flowId: e.flowId,
804
+ cancelledBy: "admin",
805
+ reason: reason ?? null,
806
+ cascade: cascade ?? false,
807
+ emittedAt: new Date(),
808
+ });
809
+ }
810
+ cancelled.push(eid);
811
+ }
812
+ if (cascade) {
813
+ const children = await deps.entities.findByParentId(eid);
814
+ for (const child of children) {
815
+ await cancelOne(child.id, depth + 1, visited);
816
+ }
764
817
  }
765
818
  }
766
- await deps.entities.cancelEntity(v.data.entity_id);
767
- await deps.transitions.record({
768
- entityId: v.data.entity_id,
769
- fromState: entity.state,
770
- toState: "cancelled",
771
- trigger: "admin.cancel",
772
- invocationId: null,
773
- timestamp: new Date(),
774
- });
775
- return jsonResult({ cancelled: true, entity_id: v.data.entity_id });
819
+ const run = () => cancelOne(entity_id, 0, new Set());
820
+ if (deps.withTransaction) {
821
+ await deps.withTransaction(run);
822
+ }
823
+ else {
824
+ await run();
825
+ }
826
+ return jsonResult({ cancelled: true, entity_id, cancelled_count: cancelled.length, cancelled_ids: cancelled });
776
827
  }
777
828
  async function handleAdminEntityReset(deps, args) {
778
829
  const v = validateInput(AdminEntityResetSchema, args);
@@ -804,6 +855,32 @@ async function handleAdminEntityReset(deps, args) {
804
855
  });
805
856
  return jsonResult({ reset: true, entity_id: v.data.entity_id, state: updated.state });
806
857
  }
858
+ async function handleAdminEntityMigrate(deps, args) {
859
+ const v = validateInput(AdminEntityMigrateSchema, args);
860
+ if (!v.ok)
861
+ return v.result;
862
+ const { entity_id: entityId } = v.data;
863
+ const entity = await deps.entities.get(entityId);
864
+ if (!entity)
865
+ return errorResult(`Entity not found: ${entityId}`);
866
+ const flow = await deps.flows.get(entity.flowId);
867
+ if (!flow)
868
+ return errorResult(`Flow not found: ${entity.flowId}`);
869
+ if (entity.flowVersion === flow.version) {
870
+ return jsonResult({
871
+ migrated: false,
872
+ message: "Entity is already on the latest version",
873
+ flowVersion: flow.version,
874
+ });
875
+ }
876
+ const stateExists = flow.states.some((s) => s.name === entity.state);
877
+ if (!stateExists) {
878
+ return errorResult(`Cannot migrate entity ${entityId}: current state '${entity.state}' does not exist in flow '${flow.name}' version ${flow.version}`);
879
+ }
880
+ await deps.entities.updateFlowVersion(entityId, flow.version);
881
+ const updated = await deps.entities.get(entityId);
882
+ return jsonResult(updated);
883
+ }
807
884
  async function handleAdminWorkerDrain(deps, args) {
808
885
  const v = validateInput(AdminWorkerDrainSchema, args);
809
886
  if (!v.ok)
@@ -6,7 +6,7 @@ export declare class DrizzleEntityRepository implements IEntityRepository {
6
6
  private db;
7
7
  constructor(db: Db);
8
8
  private toEntity;
9
- create(flowId: string, initialState: string, refs?: Refs): Promise<Entity>;
9
+ create(flowId: string, initialState: string, refs?: Refs, flowVersion?: number, parentEntityId?: string): Promise<Entity>;
10
10
  get(id: string): Promise<Entity | null>;
11
11
  findByFlowAndState(flowId: string, state: string, limit?: number): Promise<Entity[]>;
12
12
  hasAnyInFlowAndState(flowId: string, stateNames: string[]): Promise<boolean>;
@@ -23,7 +23,9 @@ export declare class DrizzleEntityRepository implements IEntityRepository {
23
23
  reapExpired(ttlMs: number): Promise<string[]>;
24
24
  setAffinity(entityId: string, workerId: string, role: string, expiresAt: Date): Promise<void>;
25
25
  clearExpiredAffinity(): Promise<string[]>;
26
+ findByParentId(parentEntityId: string): Promise<Entity[]>;
26
27
  cancelEntity(entityId: string): Promise<void>;
27
28
  resetEntity(entityId: string, targetState: string): Promise<Entity>;
29
+ updateFlowVersion(entityId: string, version: number): Promise<void>;
28
30
  }
29
31
  export {};
@@ -22,9 +22,10 @@ export class DrizzleEntityRepository {
22
22
  affinityWorkerId: row.affinityWorkerId ?? null,
23
23
  affinityRole: row.affinityRole ?? null,
24
24
  affinityExpiresAt: row.affinityExpiresAt ? new Date(row.affinityExpiresAt) : null,
25
+ parentEntityId: row.parentEntityId ?? null,
25
26
  };
26
27
  }
27
- async create(flowId, initialState, refs) {
28
+ async create(flowId, initialState, refs, flowVersion, parentEntityId) {
28
29
  const now = Date.now();
29
30
  const id = crypto.randomUUID();
30
31
  const row = {
@@ -35,12 +36,13 @@ export class DrizzleEntityRepository {
35
36
  artifacts: null,
36
37
  claimedBy: null,
37
38
  claimedAt: null,
38
- flowVersion: 1,
39
+ flowVersion: flowVersion ?? 1,
39
40
  createdAt: now,
40
41
  updatedAt: now,
41
42
  affinityWorkerId: null,
42
43
  affinityRole: null,
43
44
  affinityExpiresAt: null,
45
+ parentEntityId: parentEntityId ?? null,
44
46
  };
45
47
  await this.db.insert(entities).values(row);
46
48
  return this.toEntity(row);
@@ -188,6 +190,10 @@ export class DrizzleEntityRepository {
188
190
  .all();
189
191
  return rows.map((r) => r.id);
190
192
  }
193
+ async findByParentId(parentEntityId) {
194
+ const rows = await this.db.select().from(entities).where(eq(entities.parentEntityId, parentEntityId));
195
+ return rows.map((r) => this.toEntity(r));
196
+ }
191
197
  async cancelEntity(entityId) {
192
198
  await this.db
193
199
  .update(entities)
@@ -205,4 +211,10 @@ export class DrizzleEntityRepository {
205
211
  throw new NotFoundError(`Entity not found: ${entityId}`);
206
212
  return this.toEntity(rows[0]);
207
213
  }
214
+ async updateFlowVersion(entityId, version) {
215
+ await this.db
216
+ .update(entities)
217
+ .set({ flowVersion: version, updatedAt: Date.now() })
218
+ .where(eq(entities.id, entityId));
219
+ }
208
220
  }
@@ -9,6 +9,7 @@ export declare class DrizzleFlowRepository implements IFlowRepository {
9
9
  create(input: CreateFlowInput): Promise<Flow>;
10
10
  get(id: string): Promise<Flow | null>;
11
11
  getByName(name: string): Promise<Flow | null>;
12
+ getAtVersion(flowId: string, version: number): Promise<Flow | null>;
12
13
  list(): Promise<Flow[]>;
13
14
  listAll(): Promise<Flow[]>;
14
15
  update(id: string, changes: UpdateFlowInput): Promise<Flow>;
@@ -116,6 +116,67 @@ export class DrizzleFlowRepository {
116
116
  return null;
117
117
  return this.hydrateFlow(rows[0]);
118
118
  }
119
+ async getAtVersion(flowId, version) {
120
+ const current = await this.get(flowId);
121
+ if (!current)
122
+ return null;
123
+ if (current.version === version)
124
+ return current;
125
+ const rows = this.db
126
+ .select()
127
+ .from(flowVersions)
128
+ .where(and(eq(flowVersions.flowId, flowId), eq(flowVersions.version, version)))
129
+ .all();
130
+ if (rows.length === 0)
131
+ return null;
132
+ const snap = rows[0].snapshot;
133
+ return {
134
+ id: flowId,
135
+ name: snap.name ?? current.name,
136
+ description: snap.description ?? null,
137
+ entitySchema: snap.entitySchema ?? null,
138
+ initialState: snap.initialState ?? current.initialState,
139
+ maxConcurrent: snap.maxConcurrent ?? 0,
140
+ maxConcurrentPerRepo: snap.maxConcurrentPerRepo ?? 0,
141
+ affinityWindowMs: snap.affinityWindowMs ?? 300000,
142
+ claimRetryAfterMs: snap.claimRetryAfterMs ?? null,
143
+ gateTimeoutMs: snap.gateTimeoutMs ?? null,
144
+ version,
145
+ createdBy: snap.createdBy ?? null,
146
+ discipline: snap.discipline ?? null,
147
+ defaultModelTier: snap.defaultModelTier ?? null,
148
+ timeoutPrompt: snap.timeoutPrompt ?? null,
149
+ paused: current.paused,
150
+ createdAt: snap.createdAt ? new Date(snap.createdAt) : null,
151
+ updatedAt: snap.updatedAt ? new Date(snap.updatedAt) : null,
152
+ states: (snap.states ?? []).map((s) => ({
153
+ id: s.id,
154
+ flowId,
155
+ name: s.name,
156
+ agentRole: s.agentRole ?? null,
157
+ modelTier: s.modelTier ?? null,
158
+ mode: s.mode ?? "passive",
159
+ promptTemplate: s.promptTemplate ?? null,
160
+ constraints: s.constraints ?? null,
161
+ onEnter: s.onEnter ?? null,
162
+ onExit: s.onExit ?? null,
163
+ retryAfterMs: s.retryAfterMs ?? null,
164
+ })),
165
+ transitions: (snap.transitions ?? []).map((t) => ({
166
+ id: t.id,
167
+ flowId,
168
+ fromState: t.fromState,
169
+ toState: t.toState,
170
+ trigger: t.trigger,
171
+ gateId: t.gateId ?? null,
172
+ condition: t.condition ?? null,
173
+ priority: t.priority ?? 0,
174
+ spawnFlow: t.spawnFlow ?? null,
175
+ spawnTemplate: t.spawnTemplate ?? null,
176
+ createdAt: t.createdAt ? new Date(t.createdAt) : null,
177
+ })),
178
+ };
179
+ }
119
180
  async list() {
120
181
  const rows = this.db.select().from(flowDefinitions).all();
121
182
  return rows.map((row) => this.hydrateFlow(row));
@@ -306,6 +367,10 @@ export class DrizzleFlowRepository {
306
367
  createdAt: now,
307
368
  })
308
369
  .run();
370
+ tx.update(flowDefinitions)
371
+ .set({ version: version + 1, updatedAt: now })
372
+ .where(eq(flowDefinitions.id, flowId))
373
+ .run();
309
374
  return version;
310
375
  });
311
376
  return {
@@ -1326,6 +1326,25 @@ export declare const entities: import("drizzle-orm/sqlite-core").SQLiteTableWith
1326
1326
  identity: undefined;
1327
1327
  generated: undefined;
1328
1328
  }, {}, {}>;
1329
+ parentEntityId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
1330
+ name: "parent_entity_id";
1331
+ tableName: "entities";
1332
+ dataType: "string";
1333
+ columnType: "SQLiteText";
1334
+ data: string;
1335
+ driverParam: string;
1336
+ notNull: false;
1337
+ hasDefault: false;
1338
+ isPrimaryKey: false;
1339
+ isAutoincrement: false;
1340
+ hasRuntimeDefault: false;
1341
+ enumValues: [string, ...string[]];
1342
+ baseColumn: never;
1343
+ identity: undefined;
1344
+ generated: undefined;
1345
+ }, {}, {
1346
+ length: number | undefined;
1347
+ }>;
1329
1348
  };
1330
1349
  dialect: "sqlite";
1331
1350
  }>;
@@ -95,10 +95,12 @@ export const entities = sqliteTable("entities", {
95
95
  affinityWorkerId: text("affinity_worker_id"),
96
96
  affinityRole: text("affinity_role"),
97
97
  affinityExpiresAt: integer("affinity_expires_at"),
98
+ parentEntityId: text("parent_entity_id"),
98
99
  }, (table) => ({
99
100
  flowStateIdx: index("entities_flow_state_idx").on(table.flowId, table.state),
100
101
  claimIdx: index("entities_claim_idx").on(table.flowId, table.state, table.claimedBy),
101
102
  affinityIdx: index("entities_affinity_idx").on(table.affinityWorkerId, table.affinityRole, table.affinityExpiresAt),
103
+ parentIdx: index("entities_parent_idx").on(table.parentEntityId),
102
104
  }));
103
105
  export const invocations = sqliteTable("invocations", {
104
106
  id: text("id").primaryKey(),
@@ -35,6 +35,7 @@ export interface Entity {
35
35
  affinityWorkerId: string | null;
36
36
  affinityRole: string | null;
37
37
  affinityExpiresAt: Date | null;
38
+ parentEntityId: string | null;
38
39
  }
39
40
  /** A single agent invocation tied to an entity */
40
41
  export interface Invocation {
@@ -224,7 +225,7 @@ export interface CreateGateInput {
224
225
  /** Data-access contract for entity lifecycle operations. */
225
226
  export interface IEntityRepository {
226
227
  /** Create a new entity in the given flow's initial state. */
227
- create(flowId: string, initialState: string, refs?: Refs): Promise<Entity>;
228
+ create(flowId: string, initialState: string, refs?: Refs, flowVersion?: number, parentEntityId?: string): Promise<Entity>;
228
229
  /** Get an entity by ID, or null if not found. */
229
230
  get(id: string): Promise<Entity | null>;
230
231
  /** Find entities in a given flow and state, up to an optional limit. */
@@ -255,10 +256,14 @@ export interface IEntityRepository {
255
256
  childFlow: string;
256
257
  spawnedAt: string;
257
258
  }): Promise<void>;
259
+ /** Find all direct children of a parent entity. */
260
+ findByParentId(parentEntityId: string): Promise<Entity[]>;
258
261
  /** Move entity to 'cancelled' terminal state and clear claimedBy/claimedAt. */
259
262
  cancelEntity(entityId: string): Promise<void>;
260
263
  /** Move entity to targetState and clear claimedBy/claimedAt. Returns the updated entity. */
261
264
  resetEntity(entityId: string, targetState: string): Promise<Entity>;
265
+ /** Update an entity's pinned flow version. */
266
+ updateFlowVersion(entityId: string, version: number): Promise<void>;
262
267
  }
263
268
  /** Fields that can be updated on a flow's top-level definition */
264
269
  export type UpdateFlowInput = Partial<Omit<Flow, "id" | "states" | "transitions" | "createdAt" | "updatedAt">>;
@@ -276,6 +281,8 @@ export interface IFlowRepository {
276
281
  get(id: string): Promise<Flow | null>;
277
282
  /** Get a flow by unique name. Returns null if not found. */
278
283
  getByName(name: string): Promise<Flow | null>;
284
+ /** Load a flow definition at a specific version. Returns the snapshot if version < current, or live flow if version === current. */
285
+ getAtVersion(flowId: string, version: number): Promise<Flow | null>;
279
286
  /** Update a flow's top-level fields. */
280
287
  update(id: string, changes: UpdateFlowInput): Promise<Flow>;
281
288
  /** Add a state definition to a flow. */
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `entities` ADD `parent_entity_id` text;--> statement-breakpoint
2
+ CREATE INDEX `entities_parent_idx` ON `entities` (`parent_entity_id`);
@@ -106,6 +106,13 @@
106
106
  "when": 1773031139526,
107
107
  "tag": "0014_smiling_crusher_hogan",
108
108
  "breakpoints": true
109
+ },
110
+ {
111
+ "idx": 15,
112
+ "version": "6",
113
+ "when": 1773200000001,
114
+ "tag": "0015_add_parent_entity_id",
115
+ "breakpoints": true
109
116
  }
110
117
  ]
111
- }
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/defcon",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "engines": {