@wopr-network/defcon 1.6.1 → 1.7.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,10 @@ 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
+ // 2. Load flow at entity's pinned version
76
+ const flow = await this.flowRepo.getAtVersion(entity.flowId, entity.flowVersion);
77
77
  if (!flow)
78
- throw new NotFoundError(`Flow "${entity.flowId}" not found`);
78
+ throw new NotFoundError(`Flow "${entity.flowId}" version ${entity.flowVersion} not found`);
79
79
  // 3. Find transition
80
80
  const transition = findTransition(flow, entity.state, signal, { entity }, true, this.logger);
81
81
  if (!transition)
@@ -325,7 +325,7 @@ export class Engine {
325
325
  const flow = await this.flowRepo.getByName(flowName);
326
326
  if (!flow)
327
327
  throw new NotFoundError(`Flow "${flowName}" not found`);
328
- let entity = await this.entityRepo.create(flow.id, flow.initialState, refs);
328
+ let entity = await this.entityRepo.create(flow.id, flow.initialState, refs, flow.version);
329
329
  // Store any caller-supplied payload as initial artifacts so prompt templates
330
330
  // can access refs like {{entity.artifacts.refs.linear.id}}.
331
331
  if (payload && Object.keys(payload).length > 0) {
@@ -516,9 +516,11 @@ export class Engine {
516
516
  this.logger.warn(`[engine] setAffinity failed for entity ${claimed.id} worker ${worker_id} — continuing:`, err);
517
517
  }
518
518
  }
519
- const state = flow.states.find((s) => s.name === pending.stage);
519
+ const versionedFlow = await this.flowRepo.getAtVersion(claimed.flowId, claimed.flowVersion);
520
+ const effectiveFlow = versionedFlow ?? flow;
521
+ const state = effectiveFlow.states.find((s) => s.name === pending.stage);
520
522
  const build = state
521
- ? await this.buildPromptForEntity(state, claimed, flow)
523
+ ? await this.buildPromptForEntity(state, claimed, effectiveFlow)
522
524
  : { prompt: pending.prompt, context: null };
523
525
  await this.eventEmitter.emit({
524
526
  type: "entity.claimed",
@@ -543,12 +545,14 @@ export class Engine {
543
545
  const claimed = await this.entityRepo.claim(flow.id, state.name, worker_id ?? `agent:${role}`);
544
546
  if (!claimed)
545
547
  continue;
546
- const canCreate = await this.checkConcurrency(flow, claimed);
548
+ const claimedVersionedFlow = await this.flowRepo.getAtVersion(claimed.flowId, claimed.flowVersion);
549
+ const claimedEffectiveFlow = claimedVersionedFlow ?? flow;
550
+ const canCreate = await this.checkConcurrency(claimedEffectiveFlow, claimed);
547
551
  if (!canCreate) {
548
552
  await this.entityRepo.release(claimed.id, worker_id ?? `agent:${role}`);
549
553
  continue;
550
554
  }
551
- const build = await this.buildPromptForEntity(state, claimed, flow);
555
+ const build = await this.buildPromptForEntity(state, claimed, claimedEffectiveFlow);
552
556
  const invocation = await this.invocationRepo.create(claimed.id, state.name, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
553
557
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
554
558
  : undefined, state.agentRole ?? null);
@@ -647,7 +651,21 @@ export class Engine {
647
651
  activeInvocations += active;
648
652
  pendingClaims += pending;
649
653
  }
650
- return { flows: statusData, activeInvocations, pendingClaims };
654
+ const versionDistribution = {};
655
+ for (const flow of allFlows) {
656
+ const versionCounts = {};
657
+ for (const state of flow.states) {
658
+ const stateEntities = await this.entityRepo.findByFlowAndState(flow.id, state.name);
659
+ for (const e of stateEntities) {
660
+ const vKey = String(e.flowVersion);
661
+ versionCounts[vKey] = (versionCounts[vKey] ?? 0) + 1;
662
+ }
663
+ }
664
+ if (Object.keys(versionCounts).length > 0) {
665
+ versionDistribution[flow.id] = versionCounts;
666
+ }
667
+ }
668
+ return { flows: statusData, activeInvocations, pendingClaims, versionDistribution };
651
669
  }
652
670
  startReaper(intervalMs, entityTtlMs = 60_000) {
653
671
  let tickInFlight = false;
@@ -124,6 +124,9 @@ export declare const AdminEntityResetSchema: z.ZodObject<{
124
124
  entity_id: z.ZodString;
125
125
  target_state: z.ZodString;
126
126
  }, z.core.$strip>;
127
+ export declare const AdminEntityMigrateSchema: z.ZodObject<{
128
+ entity_id: z.ZodString;
129
+ }, z.core.$strip>;
127
130
  export declare const AdminWorkerDrainSchema: z.ZodObject<{
128
131
  worker_id: z.ZodString;
129
132
  }, z.core.$strip>;
@@ -133,6 +133,9 @@ export const AdminEntityResetSchema = z.object({
133
133
  entity_id: z.string().min(1),
134
134
  target_state: z.string().min(1),
135
135
  });
136
+ export const AdminEntityMigrateSchema = z.object({
137
+ entity_id: z.string().min(1),
138
+ });
136
139
  export const AdminWorkerDrainSchema = z.object({
137
140
  worker_id: z.string().min(1),
138
141
  });
@@ -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() {
@@ -362,6 +362,11 @@ const TOOL_DEFINITIONS = [
362
362
  required: ["entity_id", "target_state"],
363
363
  },
364
364
  },
365
+ {
366
+ name: "admin.entity.migrate",
367
+ description: "Migrate an entity to the latest flow version. Validates that the entity's current state exists in the new version.",
368
+ inputSchema: { type: "object", properties: { entity_id: { type: "string" } }, required: ["entity_id"] },
369
+ },
365
370
  {
366
371
  name: "admin.worker.drain",
367
372
  description: "Mark a worker as draining — claimWork() will skip it.",
@@ -465,6 +470,8 @@ export async function callToolHandler(deps, name, safeArgs, opts) {
465
470
  return await handleAdminEntityCancel(deps, safeArgs);
466
471
  case "admin.entity.reset":
467
472
  return await handleAdminEntityReset(deps, safeArgs);
473
+ case "admin.entity.migrate":
474
+ return await handleAdminEntityMigrate(deps, safeArgs);
468
475
  case "admin.worker.drain":
469
476
  return await handleAdminWorkerDrain(deps, safeArgs);
470
477
  case "admin.worker.undrain":
@@ -804,6 +811,32 @@ async function handleAdminEntityReset(deps, args) {
804
811
  });
805
812
  return jsonResult({ reset: true, entity_id: v.data.entity_id, state: updated.state });
806
813
  }
814
+ async function handleAdminEntityMigrate(deps, args) {
815
+ const v = validateInput(AdminEntityMigrateSchema, args);
816
+ if (!v.ok)
817
+ return v.result;
818
+ const { entity_id: entityId } = v.data;
819
+ const entity = await deps.entities.get(entityId);
820
+ if (!entity)
821
+ return errorResult(`Entity not found: ${entityId}`);
822
+ const flow = await deps.flows.get(entity.flowId);
823
+ if (!flow)
824
+ return errorResult(`Flow not found: ${entity.flowId}`);
825
+ if (entity.flowVersion === flow.version) {
826
+ return jsonResult({
827
+ migrated: false,
828
+ message: "Entity is already on the latest version",
829
+ flowVersion: flow.version,
830
+ });
831
+ }
832
+ const stateExists = flow.states.some((s) => s.name === entity.state);
833
+ if (!stateExists) {
834
+ return errorResult(`Cannot migrate entity ${entityId}: current state '${entity.state}' does not exist in flow '${flow.name}' version ${flow.version}`);
835
+ }
836
+ await deps.entities.updateFlowVersion(entityId, flow.version);
837
+ const updated = await deps.entities.get(entityId);
838
+ return jsonResult(updated);
839
+ }
807
840
  async function handleAdminWorkerDrain(deps, args) {
808
841
  const v = validateInput(AdminWorkerDrainSchema, args);
809
842
  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): 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>;
@@ -25,5 +25,6 @@ export declare class DrizzleEntityRepository implements IEntityRepository {
25
25
  clearExpiredAffinity(): Promise<string[]>;
26
26
  cancelEntity(entityId: string): Promise<void>;
27
27
  resetEntity(entityId: string, targetState: string): Promise<Entity>;
28
+ updateFlowVersion(entityId: string, version: number): Promise<void>;
28
29
  }
29
30
  export {};
@@ -24,7 +24,7 @@ export class DrizzleEntityRepository {
24
24
  affinityExpiresAt: row.affinityExpiresAt ? new Date(row.affinityExpiresAt) : null,
25
25
  };
26
26
  }
27
- async create(flowId, initialState, refs) {
27
+ async create(flowId, initialState, refs, flowVersion) {
28
28
  const now = Date.now();
29
29
  const id = crypto.randomUUID();
30
30
  const row = {
@@ -35,7 +35,7 @@ export class DrizzleEntityRepository {
35
35
  artifacts: null,
36
36
  claimedBy: null,
37
37
  claimedAt: null,
38
- flowVersion: 1,
38
+ flowVersion: flowVersion ?? 1,
39
39
  createdAt: now,
40
40
  updatedAt: now,
41
41
  affinityWorkerId: null,
@@ -205,4 +205,10 @@ export class DrizzleEntityRepository {
205
205
  throw new NotFoundError(`Entity not found: ${entityId}`);
206
206
  return this.toEntity(rows[0]);
207
207
  }
208
+ async updateFlowVersion(entityId, version) {
209
+ await this.db
210
+ .update(entities)
211
+ .set({ flowVersion: version, updatedAt: Date.now() })
212
+ .where(eq(entities.id, entityId));
213
+ }
208
214
  }
@@ -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 {
@@ -224,7 +224,7 @@ export interface CreateGateInput {
224
224
  /** Data-access contract for entity lifecycle operations. */
225
225
  export interface IEntityRepository {
226
226
  /** Create a new entity in the given flow's initial state. */
227
- create(flowId: string, initialState: string, refs?: Refs): Promise<Entity>;
227
+ create(flowId: string, initialState: string, refs?: Refs, flowVersion?: number): Promise<Entity>;
228
228
  /** Get an entity by ID, or null if not found. */
229
229
  get(id: string): Promise<Entity | null>;
230
230
  /** Find entities in a given flow and state, up to an optional limit. */
@@ -259,6 +259,8 @@ export interface IEntityRepository {
259
259
  cancelEntity(entityId: string): Promise<void>;
260
260
  /** Move entity to targetState and clear claimedBy/claimedAt. Returns the updated entity. */
261
261
  resetEntity(entityId: string, targetState: string): Promise<Entity>;
262
+ /** Update an entity's pinned flow version. */
263
+ updateFlowVersion(entityId: string, version: number): Promise<void>;
262
264
  }
263
265
  /** Fields that can be updated on a flow's top-level definition */
264
266
  export type UpdateFlowInput = Partial<Omit<Flow, "id" | "states" | "transitions" | "createdAt" | "updatedAt">>;
@@ -276,6 +278,8 @@ export interface IFlowRepository {
276
278
  get(id: string): Promise<Flow | null>;
277
279
  /** Get a flow by unique name. Returns null if not found. */
278
280
  getByName(name: string): Promise<Flow | null>;
281
+ /** Load a flow definition at a specific version. Returns the snapshot if version < current, or live flow if version === current. */
282
+ getAtVersion(flowId: string, version: number): Promise<Flow | null>;
279
283
  /** Update a flow's top-level fields. */
280
284
  update(id: string, changes: UpdateFlowInput): Promise<Flow>;
281
285
  /** Add a state definition to a flow. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/defcon",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "engines": {