@wopr-network/defcon 1.5.0 → 1.6.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.
@@ -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`
@@ -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;
@@ -67,6 +68,29 @@ export class Engine {
67
68
  const trigger = routing.kind === "redirect" ? routing.trigger : signal;
68
69
  const spawnFlow = routing.kind === "redirect" ? null : transition.spawnFlow;
69
70
  const { gatesPassed } = routing;
71
+ // 4b. Execute onExit hook on the DEPARTING state (before transition)
72
+ const departingStateDef = flow.states.find((s) => s.name === entity.state);
73
+ if (departingStateDef?.onExit) {
74
+ const onExitResult = await executeOnExit(departingStateDef.onExit, entity);
75
+ if (onExitResult.error) {
76
+ this.logger.warn(`[engine] onExit failed for entity ${entityId} state ${entity.state}: ${onExitResult.error}`);
77
+ await this.eventEmitter.emit({
78
+ type: "onExit.failed",
79
+ entityId,
80
+ state: entity.state,
81
+ error: onExitResult.error,
82
+ emittedAt: new Date(),
83
+ });
84
+ }
85
+ else {
86
+ await this.eventEmitter.emit({
87
+ type: "onExit.completed",
88
+ entityId,
89
+ state: entity.state,
90
+ emittedAt: new Date(),
91
+ });
92
+ }
93
+ }
70
94
  // 5. Transition entity
71
95
  let updated = await this.entityRepo.transition(entityId, toState, trigger, artifacts);
72
96
  // Clear gate_failures on successful transition so stale failures don't bleed into future agent prompts
@@ -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
+ }
@@ -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;