@wopr-network/defcon 1.7.0 → 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.
@@ -72,6 +72,9 @@ 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
+ if (entity.state === "cancelled") {
76
+ throw new ValidationError(`Entity "${entityId}" is cancelled and cannot be transitioned`);
77
+ }
75
78
  // 2. Load flow at entity's pinned version
76
79
  const flow = await this.flowRepo.getAtVersion(entity.flowId, entity.flowVersion);
77
80
  if (!flow)
@@ -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)
@@ -545,6 +550,10 @@ export class Engine {
545
550
  const claimed = await this.entityRepo.claim(flow.id, state.name, worker_id ?? `agent:${role}`);
546
551
  if (!claimed)
547
552
  continue;
553
+ if (claimed.state === "cancelled") {
554
+ await this.entityRepo.release(claimed.id, worker_id ?? `agent:${role}`);
555
+ continue;
556
+ }
548
557
  const claimedVersionedFlow = await this.flowRepo.getAtVersion(claimed.flowId, claimed.flowVersion);
549
558
  const claimedEffectiveFlow = claimedVersionedFlow ?? flow;
550
559
  const canCreate = await this.checkConcurrency(claimedEffectiveFlow, claimed);
@@ -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,6 +119,8 @@ 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;
@@ -128,6 +128,8 @@ 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),
@@ -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 */
@@ -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",
@@ -755,31 +763,67 @@ async function handleAdminEntityCancel(deps, args) {
755
763
  const v = validateInput(AdminEntityCancelSchema, args);
756
764
  if (!v.ok)
757
765
  return v.result;
758
- 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);
759
768
  if (!entity)
760
- return errorResult(`Entity not found: ${v.data.entity_id}`);
761
- const flow = await deps.flows.get(entity.flowId);
762
- if (!flow)
763
- return errorResult(`Flow not found for entity: ${v.data.entity_id}`);
764
- const cancelledState = flow.states.find((s) => s.name === "cancelled");
765
- if (!cancelledState)
766
- return errorResult(`State 'cancelled' not found in flow '${flow.name}'`);
767
- const invocations = await deps.invocations.findByEntity(v.data.entity_id);
768
- for (const inv of invocations) {
769
- if (inv.completedAt === null && inv.failedAt === null) {
770
- 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
+ }
771
817
  }
772
818
  }
773
- await deps.entities.cancelEntity(v.data.entity_id);
774
- await deps.transitions.record({
775
- entityId: v.data.entity_id,
776
- fromState: entity.state,
777
- toState: "cancelled",
778
- trigger: "admin.cancel",
779
- invocationId: null,
780
- timestamp: new Date(),
781
- });
782
- 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 });
783
827
  }
784
828
  async function handleAdminEntityReset(deps, args) {
785
829
  const v = validateInput(AdminEntityResetSchema, args);
@@ -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, flowVersion?: number): 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,6 +23,7 @@ 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>;
28
29
  updateFlowVersion(entityId: string, version: number): Promise<void>;
@@ -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, flowVersion) {
28
+ async create(flowId, initialState, refs, flowVersion, parentEntityId) {
28
29
  const now = Date.now();
29
30
  const id = crypto.randomUUID();
30
31
  const row = {
@@ -41,6 +42,7 @@ export class DrizzleEntityRepository {
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)
@@ -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, flowVersion?: number): 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,6 +256,8 @@ 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. */
@@ -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.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "engines": {