@wopr-network/defcon 1.5.0 → 1.6.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.
@@ -93,6 +93,7 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
93
93
  promptTemplate: s.promptTemplate,
94
94
  constraints: s.constraints,
95
95
  onEnter: s.onEnter,
96
+ onExit: s.onExit,
96
97
  retryAfterMs: s.retryAfterMs,
97
98
  });
98
99
  }
@@ -20,6 +20,10 @@ export declare const OnEnterSchema: z.ZodObject<{
20
20
  artifacts: z.ZodArray<z.ZodString>;
21
21
  timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
22
22
  }, z.core.$strip>;
23
+ export declare const OnExitSchema: z.ZodObject<{
24
+ command: z.ZodString;
25
+ timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
26
+ }, z.core.$strip>;
23
27
  export declare const StateDefinitionSchema: z.ZodObject<{
24
28
  name: z.ZodString;
25
29
  flowName: z.ZodString;
@@ -36,6 +40,10 @@ export declare const StateDefinitionSchema: z.ZodObject<{
36
40
  artifacts: z.ZodArray<z.ZodString>;
37
41
  timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
38
42
  }, z.core.$strip>>;
43
+ onExit: z.ZodOptional<z.ZodObject<{
44
+ command: z.ZodString;
45
+ timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
46
+ }, z.core.$strip>>;
39
47
  retryAfterMs: z.ZodOptional<z.ZodNumber>;
40
48
  }, z.core.$strip>;
41
49
  export declare const CommandGateSchema: z.ZodObject<{
@@ -152,6 +160,10 @@ export declare const SeedFileSchema: z.ZodObject<{
152
160
  artifacts: z.ZodArray<z.ZodString>;
153
161
  timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
154
162
  }, z.core.$strip>>;
163
+ onExit: z.ZodOptional<z.ZodObject<{
164
+ command: z.ZodString;
165
+ timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
166
+ }, z.core.$strip>>;
155
167
  retryAfterMs: z.ZodOptional<z.ZodNumber>;
156
168
  }, z.core.$strip>>;
157
169
  gates: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -34,6 +34,15 @@ export const OnEnterSchema = z.object({
34
34
  artifacts: z.array(z.string().min(1)).min(1),
35
35
  timeout_ms: z.number().int().min(0).optional().default(30000),
36
36
  });
37
+ export const OnExitSchema = z.object({
38
+ command: z
39
+ .string()
40
+ .min(1)
41
+ .refine((val) => validateTemplate(val), {
42
+ message: "onExit command contains disallowed Handlebars expressions",
43
+ }),
44
+ timeout_ms: z.number().int().min(1).optional().default(30000),
45
+ });
37
46
  export const StateDefinitionSchema = z.object({
38
47
  name: z.string().min(1),
39
48
  flowName: z.string().min(1),
@@ -49,6 +58,7 @@ export const StateDefinitionSchema = z.object({
49
58
  .optional(),
50
59
  constraints: z.record(z.string(), z.unknown()).optional(),
51
60
  onEnter: OnEnterSchema.optional(),
61
+ onExit: OnExitSchema.optional(),
52
62
  retryAfterMs: z.number().int().min(0).optional(),
53
63
  });
54
64
  // Gate: discriminated union on `type`
@@ -38,6 +38,8 @@ export interface EngineDeps {
38
38
  adapters: Map<string, unknown>;
39
39
  eventEmitter: IEventBusAdapter;
40
40
  logger?: Logger;
41
+ /** Optional transaction wrapper from the database layer. When provided, processSignal runs inside a single transaction and events are flushed only after successful commit. */
42
+ withTransaction?: <T>(fn: () => T | Promise<T>) => Promise<T>;
41
43
  }
42
44
  export declare class Engine {
43
45
  private entityRepo;
@@ -48,6 +50,7 @@ export declare class Engine {
48
50
  readonly adapters: Map<string, unknown>;
49
51
  private eventEmitter;
50
52
  private readonly logger;
53
+ private readonly withTransactionFn;
51
54
  private drainingWorkers;
52
55
  constructor(deps: EngineDeps);
53
56
  drainWorker(workerId: string): void;
@@ -56,6 +59,7 @@ export declare class Engine {
56
59
  listDrainingWorkers(): string[];
57
60
  emit(event: import("./event-types.js").EngineEvent): Promise<void>;
58
61
  processSignal(entityId: string, signal: string, artifacts?: Artifacts, triggeringInvocationId?: string): Promise<ProcessSignalResult>;
62
+ private _processSignalInner;
59
63
  /**
60
64
  * Evaluate a gate and return a routing decision:
61
65
  * - `proceed` — gate passed, continue to transition.toState
@@ -6,6 +6,7 @@ import { evaluateGate } from "./gate-evaluator.js";
6
6
  import { getHandlebars } from "./handlebars.js";
7
7
  import { buildInvocation } from "./invocation-builder.js";
8
8
  import { executeOnEnter } from "./on-enter.js";
9
+ import { executeOnExit } from "./on-exit.js";
9
10
  import { findTransition, isTerminal } from "./state-machine.js";
10
11
  export class Engine {
11
12
  entityRepo;
@@ -16,6 +17,7 @@ export class Engine {
16
17
  adapters;
17
18
  eventEmitter;
18
19
  logger;
20
+ withTransactionFn;
19
21
  drainingWorkers = new Set();
20
22
  constructor(deps) {
21
23
  this.entityRepo = deps.entityRepo;
@@ -26,6 +28,7 @@ export class Engine {
26
28
  this.adapters = deps.adapters;
27
29
  this.eventEmitter = deps.eventEmitter;
28
30
  this.logger = deps.logger ?? consoleLogger;
31
+ this.withTransactionFn = deps.withTransaction ?? null;
29
32
  }
30
33
  drainWorker(workerId) {
31
34
  this.drainingWorkers.add(workerId);
@@ -43,6 +46,28 @@ export class Engine {
43
46
  await this.eventEmitter.emit(event);
44
47
  }
45
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) {
46
71
  // 1. Load entity
47
72
  const entity = await this.entityRepo.get(entityId);
48
73
  if (!entity)
@@ -67,6 +92,29 @@ export class Engine {
67
92
  const trigger = routing.kind === "redirect" ? routing.trigger : signal;
68
93
  const spawnFlow = routing.kind === "redirect" ? null : transition.spawnFlow;
69
94
  const { gatesPassed } = routing;
95
+ // 4b. Execute onExit hook on the DEPARTING state (before transition)
96
+ const departingStateDef = flow.states.find((s) => s.name === entity.state);
97
+ if (departingStateDef?.onExit) {
98
+ const onExitResult = await executeOnExit(departingStateDef.onExit, entity);
99
+ if (onExitResult.error) {
100
+ this.logger.warn(`[engine] onExit failed for entity ${entityId} state ${entity.state}: ${onExitResult.error}`);
101
+ await this.eventEmitter.emit({
102
+ type: "onExit.failed",
103
+ entityId,
104
+ state: entity.state,
105
+ error: onExitResult.error,
106
+ emittedAt: new Date(),
107
+ });
108
+ }
109
+ else {
110
+ await this.eventEmitter.emit({
111
+ type: "onExit.completed",
112
+ entityId,
113
+ state: entity.state,
114
+ emittedAt: new Date(),
115
+ });
116
+ }
117
+ }
70
118
  // 5. Transition entity
71
119
  let updated = await this.entityRepo.transition(entityId, toState, trigger, artifacts);
72
120
  // Clear gate_failures on successful transition so stale failures don't bleed into future agent prompts
@@ -74,7 +122,7 @@ export class Engine {
74
122
  // Keep the in-memory entity in sync so buildInvocation sees the cleared failures
75
123
  updated = { ...updated, artifacts: { ...updated.artifacts, gate_failures: [] } };
76
124
  // 6. Emit transition event
77
- await this.eventEmitter.emit({
125
+ await emitter.emit({
78
126
  type: "entity.transitioned",
79
127
  entityId,
80
128
  flowId: flow.id,
@@ -94,7 +142,7 @@ export class Engine {
94
142
  if (newStateDef?.onEnter) {
95
143
  const onEnterResult = await executeOnEnter(newStateDef.onEnter, updated, this.entityRepo);
96
144
  if (onEnterResult.skipped) {
97
- await this.eventEmitter.emit({
145
+ await emitter.emit({
98
146
  type: "onEnter.skipped",
99
147
  entityId,
100
148
  state: toState,
@@ -102,7 +150,7 @@ export class Engine {
102
150
  });
103
151
  }
104
152
  else if (onEnterResult.error) {
105
- await this.eventEmitter.emit({
153
+ await emitter.emit({
106
154
  type: "onEnter.failed",
107
155
  entityId,
108
156
  state: toState,
@@ -127,7 +175,7 @@ export class Engine {
127
175
  };
128
176
  }
129
177
  else {
130
- await this.eventEmitter.emit({
178
+ await emitter.emit({
131
179
  type: "onEnter.completed",
132
180
  entityId,
133
181
  state: toState,
@@ -155,7 +203,7 @@ export class Engine {
155
203
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
156
204
  : undefined, newStateDef.agentRole ?? null);
157
205
  result.invocationId = invocation.id;
158
- await this.eventEmitter.emit({
206
+ await emitter.emit({
159
207
  type: "invocation.created",
160
208
  entityId,
161
209
  invocationId: invocation.id,
@@ -178,7 +226,7 @@ export class Engine {
178
226
  const spawned = await executeSpawn({ spawnFlow }, updated, this.flowRepo, this.entityRepo, this.logger);
179
227
  if (spawned) {
180
228
  result.spawned = [spawned.id];
181
- await this.eventEmitter.emit({
229
+ await emitter.emit({
182
230
  type: "flow.spawned",
183
231
  entityId,
184
232
  flowId: flow.id,
@@ -104,6 +104,17 @@ export type EngineEvent = {
104
104
  entityId: string;
105
105
  state: string;
106
106
  emittedAt: Date;
107
+ } | {
108
+ type: "onExit.completed";
109
+ entityId: string;
110
+ state: string;
111
+ emittedAt: Date;
112
+ } | {
113
+ type: "onExit.failed";
114
+ entityId: string;
115
+ state: string;
116
+ error: string;
117
+ emittedAt: Date;
107
118
  };
108
119
  /** Adapter for broadcasting engine events to external systems. */
109
120
  export interface IEventBusAdapter {
@@ -0,0 +1,6 @@
1
+ import type { Entity, OnExitConfig } from "../repositories/interfaces.js";
2
+ export interface OnExitResult {
3
+ error: string | null;
4
+ timedOut: boolean;
5
+ }
6
+ export declare function executeOnExit(onExit: OnExitConfig, entity: Entity): Promise<OnExitResult>;
@@ -0,0 +1,45 @@
1
+ import { execFile } from "node:child_process";
2
+ import { getHandlebars } from "./handlebars.js";
3
+ export async function executeOnExit(onExit, entity) {
4
+ const hbs = getHandlebars();
5
+ let renderedCommand;
6
+ // Merge artifact refs into entity.refs (same pattern as on-enter.ts)
7
+ const artifactRefs = entity.artifacts !== null &&
8
+ typeof entity.artifacts === "object" &&
9
+ "refs" in entity.artifacts &&
10
+ entity.artifacts.refs !== null &&
11
+ typeof entity.artifacts.refs === "object"
12
+ ? entity.artifacts.refs
13
+ : {};
14
+ const entityForContext = { ...entity, refs: { ...artifactRefs, ...(entity.refs ?? {}) } };
15
+ try {
16
+ renderedCommand = hbs.compile(onExit.command)({ entity: entityForContext });
17
+ }
18
+ catch (err) {
19
+ const error = `onExit template error: ${err instanceof Error ? err.message : String(err)}`;
20
+ return { error, timedOut: false };
21
+ }
22
+ const timeoutMs = onExit.timeout_ms ?? 30000;
23
+ const { exitCode, stdout, stderr, timedOut } = await runCommand(renderedCommand, timeoutMs);
24
+ if (timedOut) {
25
+ return { error: `onExit command timed out after ${timeoutMs}ms`, timedOut: true };
26
+ }
27
+ if (exitCode !== 0) {
28
+ return { error: `onExit command exited with code ${exitCode}: ${stderr || stdout}`, timedOut: false };
29
+ }
30
+ return { error: null, timedOut: false };
31
+ }
32
+ function runCommand(command, timeoutMs) {
33
+ return new Promise((resolve) => {
34
+ execFile("/bin/sh", ["-c", command], { timeout: timeoutMs }, (error, stdout, stderr) => {
35
+ const execErr = error;
36
+ const timedOut = execErr !== null && execErr.killed === true;
37
+ resolve({
38
+ exitCode: execErr ? (typeof execErr.code === "number" ? execErr.code : 1) : 0,
39
+ stdout: stdout.trim(),
40
+ stderr: stderr.trim(),
41
+ timedOut,
42
+ });
43
+ });
44
+ });
45
+ }
@@ -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,
@@ -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
  }
@@ -15,6 +15,7 @@ function rowToState(r) {
15
15
  promptTemplate: r.promptTemplate ?? null,
16
16
  constraints: r.constraints,
17
17
  onEnter: r.onEnter ?? null,
18
+ onExit: r.onExit ?? null,
18
19
  retryAfterMs: r.retryAfterMs ?? null,
19
20
  };
20
21
  }
@@ -174,6 +175,7 @@ export class DrizzleFlowRepository {
174
175
  promptTemplate: state.promptTemplate ?? null,
175
176
  constraints: (state.constraints ?? null),
176
177
  onEnter: (state.onEnter ?? null),
178
+ onExit: (state.onExit ?? null),
177
179
  retryAfterMs: state.retryAfterMs ?? null,
178
180
  };
179
181
  this.db.transaction((tx) => {
@@ -201,6 +203,8 @@ export class DrizzleFlowRepository {
201
203
  updateValues.constraints = changes.constraints;
202
204
  if (changes.onEnter !== undefined)
203
205
  updateValues.onEnter = changes.onEnter;
206
+ if (changes.onExit !== undefined)
207
+ updateValues.onExit = changes.onExit;
204
208
  if (changes.retryAfterMs !== undefined)
205
209
  updateValues.retryAfterMs = changes.retryAfterMs;
206
210
  if (Object.keys(updateValues).length > 0) {
@@ -338,6 +342,7 @@ export class DrizzleFlowRepository {
338
342
  promptTemplate: s.promptTemplate,
339
343
  constraints: s.constraints,
340
344
  onEnter: (s.onEnter ?? null),
345
+ onExit: (s.onExit ?? null),
341
346
  retryAfterMs: s.retryAfterMs ?? null,
342
347
  })
343
348
  .run();
@@ -498,6 +498,23 @@ export declare const stateDefinitions: import("drizzle-orm/sqlite-core").SQLiteT
498
498
  identity: undefined;
499
499
  generated: undefined;
500
500
  }, {}, {}>;
501
+ onExit: import("drizzle-orm/sqlite-core").SQLiteColumn<{
502
+ name: "on_exit";
503
+ tableName: "state_definitions";
504
+ dataType: "json";
505
+ columnType: "SQLiteTextJson";
506
+ data: unknown;
507
+ driverParam: string;
508
+ notNull: false;
509
+ hasDefault: false;
510
+ isPrimaryKey: false;
511
+ isAutoincrement: false;
512
+ hasRuntimeDefault: false;
513
+ enumValues: undefined;
514
+ baseColumn: never;
515
+ identity: undefined;
516
+ generated: undefined;
517
+ }, {}, {}>;
501
518
  retryAfterMs: import("drizzle-orm/sqlite-core").SQLiteColumn<{
502
519
  name: "retry_after_ms";
503
520
  tableName: "state_definitions";
@@ -32,6 +32,7 @@ export const stateDefinitions = sqliteTable("state_definitions", {
32
32
  promptTemplate: text("prompt_template"),
33
33
  constraints: text("constraints", { mode: "json" }),
34
34
  onEnter: text("on_enter", { mode: "json" }),
35
+ onExit: text("on_exit", { mode: "json" }),
35
36
  retryAfterMs: integer("retry_after_ms"),
36
37
  }, (table) => ({
37
38
  flowNameUnique: uniqueIndex("state_definitions_flow_name_unique").on(table.flowId, table.name),
@@ -12,6 +12,11 @@ export interface OnEnterConfig {
12
12
  artifacts: string[];
13
13
  timeout_ms?: number;
14
14
  }
15
+ /** Configuration for running a cleanup command when an entity exits a state */
16
+ export interface OnExitConfig {
17
+ command: string;
18
+ timeout_ms?: number;
19
+ }
15
20
  /** Invocation execution mode */
16
21
  export type Mode = "active" | "passive";
17
22
  /** Runtime entity tracked through a flow */
@@ -90,6 +95,7 @@ export interface State {
90
95
  promptTemplate: string | null;
91
96
  constraints: Record<string, unknown> | null;
92
97
  onEnter: OnEnterConfig | null;
98
+ onExit: OnExitConfig | null;
93
99
  /** Override check_back delay for workers claiming this state. Falls back to Flow.claimRetryAfterMs. */
94
100
  retryAfterMs: number | null;
95
101
  }
@@ -186,6 +192,7 @@ export interface CreateStateInput {
186
192
  promptTemplate?: string;
187
193
  constraints?: Record<string, unknown>;
188
194
  onEnter?: OnEnterConfig;
195
+ onExit?: OnExitConfig;
189
196
  retryAfterMs?: number;
190
197
  }
191
198
  /** Input for adding a transition rule */
@@ -0,0 +1 @@
1
+ ALTER TABLE `state_definitions` ADD `on_exit` text;