@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.
- package/dist/src/engine/engine.js +9 -0
- package/dist/src/engine/event-types.d.ts +8 -0
- package/dist/src/engine/flow-spawner.js +1 -1
- package/dist/src/execution/admin-schemas.d.ts +2 -0
- package/dist/src/execution/admin-schemas.js +2 -0
- package/dist/src/execution/cli.js +1 -0
- package/dist/src/execution/mcp-server.d.ts +1 -0
- package/dist/src/execution/mcp-server.js +68 -24
- package/dist/src/repositories/drizzle/entity.repo.d.ts +2 -1
- package/dist/src/repositories/drizzle/entity.repo.js +7 -1
- package/dist/src/repositories/drizzle/schema.d.ts +19 -0
- package/dist/src/repositories/drizzle/schema.js +2 -0
- package/dist/src/repositories/interfaces.d.ts +4 -1
- package/drizzle/0015_add_parent_entity_id.sql +2 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +1 -1
|
@@ -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: {
|
|
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
|
|
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: ${
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (
|
|
770
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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. */
|
|
@@ -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
|
+
}
|