@wopr-network/defcon 1.6.0 → 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;
@@ -38,6 +39,8 @@ export interface EngineDeps {
38
39
  adapters: Map<string, unknown>;
39
40
  eventEmitter: IEventBusAdapter;
40
41
  logger?: Logger;
42
+ /** Optional transaction wrapper from the database layer. When provided, processSignal runs inside a single transaction and events are flushed only after successful commit. */
43
+ withTransaction?: <T>(fn: () => T | Promise<T>) => Promise<T>;
41
44
  }
42
45
  export declare class Engine {
43
46
  private entityRepo;
@@ -48,6 +51,7 @@ export declare class Engine {
48
51
  readonly adapters: Map<string, unknown>;
49
52
  private eventEmitter;
50
53
  private readonly logger;
54
+ private readonly withTransactionFn;
51
55
  private drainingWorkers;
52
56
  constructor(deps: EngineDeps);
53
57
  drainWorker(workerId: string): void;
@@ -56,6 +60,7 @@ export declare class Engine {
56
60
  listDrainingWorkers(): string[];
57
61
  emit(event: import("./event-types.js").EngineEvent): Promise<void>;
58
62
  processSignal(entityId: string, signal: string, artifacts?: Artifacts, triggeringInvocationId?: string): Promise<ProcessSignalResult>;
63
+ private _processSignalInner;
59
64
  /**
60
65
  * Evaluate a gate and return a routing decision:
61
66
  * - `proceed` — gate passed, continue to transition.toState
@@ -17,6 +17,7 @@ export class Engine {
17
17
  adapters;
18
18
  eventEmitter;
19
19
  logger;
20
+ withTransactionFn;
20
21
  drainingWorkers = new Set();
21
22
  constructor(deps) {
22
23
  this.entityRepo = deps.entityRepo;
@@ -27,6 +28,7 @@ export class Engine {
27
28
  this.adapters = deps.adapters;
28
29
  this.eventEmitter = deps.eventEmitter;
29
30
  this.logger = deps.logger ?? consoleLogger;
31
+ this.withTransactionFn = deps.withTransaction ?? null;
30
32
  }
31
33
  drainWorker(workerId) {
32
34
  this.drainingWorkers.add(workerId);
@@ -44,14 +46,36 @@ export class Engine {
44
46
  await this.eventEmitter.emit(event);
45
47
  }
46
48
  async processSignal(entityId, signal, artifacts, triggeringInvocationId) {
49
+ const pendingEvents = [];
50
+ const bufferingEmitter = {
51
+ emit: async (event) => {
52
+ pendingEvents.push(event);
53
+ },
54
+ };
55
+ if (this.withTransactionFn) {
56
+ // Run DB writes inside a transaction; events are buffered and only
57
+ // flushed to real subscribers after successful COMMIT.
58
+ const result = await this.withTransactionFn(() => this._processSignalInner(entityId, signal, artifacts, triggeringInvocationId, bufferingEmitter));
59
+ for (const event of pendingEvents) {
60
+ await this.eventEmitter.emit(event);
61
+ }
62
+ return result;
63
+ }
64
+ const result = await this._processSignalInner(entityId, signal, artifacts, triggeringInvocationId, bufferingEmitter);
65
+ for (const event of pendingEvents) {
66
+ await this.eventEmitter.emit(event);
67
+ }
68
+ return result;
69
+ }
70
+ async _processSignalInner(entityId, signal, artifacts, triggeringInvocationId, emitter = this.eventEmitter) {
47
71
  // 1. Load entity
48
72
  const entity = await this.entityRepo.get(entityId);
49
73
  if (!entity)
50
74
  throw new NotFoundError(`Entity "${entityId}" not found`);
51
- // 2. Load flow
52
- 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);
53
77
  if (!flow)
54
- throw new NotFoundError(`Flow "${entity.flowId}" not found`);
78
+ throw new NotFoundError(`Flow "${entity.flowId}" version ${entity.flowVersion} not found`);
55
79
  // 3. Find transition
56
80
  const transition = findTransition(flow, entity.state, signal, { entity }, true, this.logger);
57
81
  if (!transition)
@@ -98,7 +122,7 @@ export class Engine {
98
122
  // Keep the in-memory entity in sync so buildInvocation sees the cleared failures
99
123
  updated = { ...updated, artifacts: { ...updated.artifacts, gate_failures: [] } };
100
124
  // 6. Emit transition event
101
- await this.eventEmitter.emit({
125
+ await emitter.emit({
102
126
  type: "entity.transitioned",
103
127
  entityId,
104
128
  flowId: flow.id,
@@ -118,7 +142,7 @@ export class Engine {
118
142
  if (newStateDef?.onEnter) {
119
143
  const onEnterResult = await executeOnEnter(newStateDef.onEnter, updated, this.entityRepo);
120
144
  if (onEnterResult.skipped) {
121
- await this.eventEmitter.emit({
145
+ await emitter.emit({
122
146
  type: "onEnter.skipped",
123
147
  entityId,
124
148
  state: toState,
@@ -126,7 +150,7 @@ export class Engine {
126
150
  });
127
151
  }
128
152
  else if (onEnterResult.error) {
129
- await this.eventEmitter.emit({
153
+ await emitter.emit({
130
154
  type: "onEnter.failed",
131
155
  entityId,
132
156
  state: toState,
@@ -151,7 +175,7 @@ export class Engine {
151
175
  };
152
176
  }
153
177
  else {
154
- await this.eventEmitter.emit({
178
+ await emitter.emit({
155
179
  type: "onEnter.completed",
156
180
  entityId,
157
181
  state: toState,
@@ -179,7 +203,7 @@ export class Engine {
179
203
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
180
204
  : undefined, newStateDef.agentRole ?? null);
181
205
  result.invocationId = invocation.id;
182
- await this.eventEmitter.emit({
206
+ await emitter.emit({
183
207
  type: "invocation.created",
184
208
  entityId,
185
209
  invocationId: invocation.id,
@@ -202,7 +226,7 @@ export class Engine {
202
226
  const spawned = await executeSpawn({ spawnFlow }, updated, this.flowRepo, this.entityRepo, this.logger);
203
227
  if (spawned) {
204
228
  result.spawned = [spawned.id];
205
- await this.eventEmitter.emit({
229
+ await emitter.emit({
206
230
  type: "flow.spawned",
207
231
  entityId,
208
232
  flowId: flow.id,
@@ -301,7 +325,7 @@ export class Engine {
301
325
  const flow = await this.flowRepo.getByName(flowName);
302
326
  if (!flow)
303
327
  throw new NotFoundError(`Flow "${flowName}" not found`);
304
- 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);
305
329
  // Store any caller-supplied payload as initial artifacts so prompt templates
306
330
  // can access refs like {{entity.artifacts.refs.linear.id}}.
307
331
  if (payload && Object.keys(payload).length > 0) {
@@ -492,9 +516,11 @@ export class Engine {
492
516
  this.logger.warn(`[engine] setAffinity failed for entity ${claimed.id} worker ${worker_id} — continuing:`, err);
493
517
  }
494
518
  }
495
- 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);
496
522
  const build = state
497
- ? await this.buildPromptForEntity(state, claimed, flow)
523
+ ? await this.buildPromptForEntity(state, claimed, effectiveFlow)
498
524
  : { prompt: pending.prompt, context: null };
499
525
  await this.eventEmitter.emit({
500
526
  type: "entity.claimed",
@@ -519,12 +545,14 @@ export class Engine {
519
545
  const claimed = await this.entityRepo.claim(flow.id, state.name, worker_id ?? `agent:${role}`);
520
546
  if (!claimed)
521
547
  continue;
522
- 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);
523
551
  if (!canCreate) {
524
552
  await this.entityRepo.release(claimed.id, worker_id ?? `agent:${role}`);
525
553
  continue;
526
554
  }
527
- const build = await this.buildPromptForEntity(state, claimed, flow);
555
+ const build = await this.buildPromptForEntity(state, claimed, claimedEffectiveFlow);
528
556
  const invocation = await this.invocationRepo.create(claimed.id, state.name, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
529
557
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
530
558
  : undefined, state.agentRole ?? null);
@@ -623,7 +651,21 @@ export class Engine {
623
651
  activeInvocations += active;
624
652
  pendingClaims += pending;
625
653
  }
626
- 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 };
627
669
  }
628
670
  startReaper(intervalMs, entityTtlMs = 60_000) {
629
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
  });
@@ -14,6 +14,7 @@ import { loadSeed } from "../config/seed-loader.js";
14
14
  import { resolveCorsOrigin } from "../cors.js";
15
15
  import { Engine } from "../engine/engine.js";
16
16
  import { EventEmitter } from "../engine/event-emitter.js";
17
+ import { withTransaction } from "../main.js";
17
18
  import { DrizzleEntityRepository } from "../repositories/drizzle/entity.repo.js";
18
19
  import { DrizzleEventRepository } from "../repositories/drizzle/event.repo.js";
19
20
  import { DrizzleFlowRepository } from "../repositories/drizzle/flow.repo.js";
@@ -173,6 +174,7 @@ program
173
174
  transitionLogRepo,
174
175
  adapters: new Map(),
175
176
  eventEmitter,
177
+ withTransaction: (fn) => withTransaction(sqlite, fn),
176
178
  });
177
179
  const deps = {
178
180
  entities: entityRepo,
@@ -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)
@@ -5,6 +5,12 @@ export declare function createDatabase(dbPath?: string): {
5
5
  db: ReturnType<typeof drizzle<typeof schema>>;
6
6
  sqlite: Database.Database;
7
7
  };
8
+ /**
9
+ * Wraps `fn` in a BEGIN/COMMIT/ROLLBACK transaction on `sqlite`.
10
+ * If already inside a transaction, runs `fn` directly (allows nested calls).
11
+ * Supports both synchronous and Promise-returning `fn`.
12
+ */
13
+ export declare function withTransaction<T>(sqlite: Database.Database, fn: () => T | Promise<T>): Promise<T>;
8
14
  export declare function runMigrations(db: ReturnType<typeof drizzle>, migrationsFolder?: string): void;
9
15
  export declare function bootstrap(dbPath?: string): {
10
16
  db: ReturnType<typeof drizzle>;
package/dist/src/main.js CHANGED
@@ -10,6 +10,26 @@ export function createDatabase(dbPath = DB_PATH) {
10
10
  const db = drizzle(sqlite, { schema });
11
11
  return { db, sqlite };
12
12
  }
13
+ /**
14
+ * Wraps `fn` in a BEGIN/COMMIT/ROLLBACK transaction on `sqlite`.
15
+ * If already inside a transaction, runs `fn` directly (allows nested calls).
16
+ * Supports both synchronous and Promise-returning `fn`.
17
+ */
18
+ export async function withTransaction(sqlite, fn) {
19
+ if (sqlite.inTransaction) {
20
+ return fn();
21
+ }
22
+ sqlite.exec("BEGIN");
23
+ try {
24
+ const result = await fn();
25
+ sqlite.exec("COMMIT");
26
+ return result;
27
+ }
28
+ catch (err) {
29
+ sqlite.exec("ROLLBACK");
30
+ throw err;
31
+ }
32
+ }
13
33
  export function runMigrations(db, migrationsFolder = "./drizzle") {
14
34
  migrate(db, { migrationsFolder });
15
35
  }
@@ -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.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "engines": {