@wopr-network/defcon 1.10.0 → 1.11.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.
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from "../logger.js";
2
- import type { Artifacts, Entity, IEntityRepository, IFlowRepository, IGateRepository, IInvocationRepository, ITransitionLogRepository } from "../repositories/interfaces.js";
2
+ import type { Artifacts, Entity, IDomainEventRepository, IEntityRepository, IFlowRepository, IGateRepository, IInvocationRepository, ITransitionLogRepository } from "../repositories/interfaces.js";
3
3
  import type { IEventBusAdapter } from "./event-types.js";
4
4
  export interface ProcessSignalResult {
5
5
  newState?: string;
@@ -41,6 +41,8 @@ export interface EngineDeps {
41
41
  logger?: Logger;
42
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
43
  withTransaction?: <T>(fn: () => T | Promise<T>) => Promise<T>;
44
+ /** Optional domain event repository. When provided, claimWork uses CAS on the event log to prevent concurrent claim races. */
45
+ domainEvents?: IDomainEventRepository;
44
46
  }
45
47
  export declare class Engine {
46
48
  private entityRepo;
@@ -52,6 +54,7 @@ export declare class Engine {
52
54
  private eventEmitter;
53
55
  private readonly logger;
54
56
  private readonly withTransactionFn;
57
+ private readonly domainEventRepo;
55
58
  private drainingWorkers;
56
59
  constructor(deps: EngineDeps);
57
60
  drainWorker(workerId: string): void;
@@ -18,6 +18,7 @@ export class Engine {
18
18
  eventEmitter;
19
19
  logger;
20
20
  withTransactionFn;
21
+ domainEventRepo;
21
22
  drainingWorkers = new Set();
22
23
  constructor(deps) {
23
24
  this.entityRepo = deps.entityRepo;
@@ -29,6 +30,7 @@ export class Engine {
29
30
  this.eventEmitter = deps.eventEmitter;
30
31
  this.logger = deps.logger ?? consoleLogger;
31
32
  this.withTransactionFn = deps.withTransaction ?? null;
33
+ this.domainEventRepo = deps.domainEvents ?? null;
32
34
  }
33
35
  drainWorker(workerId) {
34
36
  this.drainingWorkers.add(workerId);
@@ -476,9 +478,30 @@ export class Engine {
476
478
  if (entity.state !== pending.stage)
477
479
  continue;
478
480
  const entityClaimToken = worker_id ?? `agent:${role}`;
481
+ // CAS guard: atomically append an invocation.claimed event using optimistic concurrency.
482
+ // Only one writer wins the unique (entityId, sequence) constraint — losers move to the next candidate.
483
+ if (this.domainEventRepo) {
484
+ const lastSeq = await this.domainEventRepo.getLastSequence(entity.id);
485
+ const casResult = await this.domainEventRepo.appendCas("invocation.claim_attempted", entity.id, { agentId: entityClaimToken, invocationId: pending.id, stage: pending.stage }, lastSeq);
486
+ if (!casResult)
487
+ continue; // Another agent won the race
488
+ }
479
489
  const claimed = await this.entityRepo.claimById(entity.id, entityClaimToken);
480
- if (!claimed)
490
+ if (!claimed) {
491
+ // claimById failed after CAS success — record rollback event for observability
492
+ if (this.domainEventRepo) {
493
+ try {
494
+ await this.domainEventRepo.append("invocation.claim_released", entity.id, {
495
+ agentId: entityClaimToken,
496
+ reason: "claimById_failed",
497
+ });
498
+ }
499
+ catch (err) {
500
+ this.logger.warn("[engine] CAS claim rollback event failed", { err });
501
+ }
502
+ }
481
503
  continue;
504
+ }
482
505
  // Post-claim state validation — entity may have transitioned between guard check and claim
483
506
  if (claimed.state !== pending.stage) {
484
507
  try {
@@ -181,6 +181,7 @@ program
181
181
  adapters: new Map(),
182
182
  eventEmitter,
183
183
  withTransaction: (fn) => withTransaction(sqlite, fn),
184
+ domainEvents: domainEventRepo,
184
185
  });
185
186
  const deps = {
186
187
  entities: entityRepo,
@@ -6,6 +6,8 @@ export declare class DrizzleDomainEventRepository implements IDomainEventReposit
6
6
  private readonly db;
7
7
  constructor(db: Db);
8
8
  append(type: string, entityId: string, payload: Record<string, unknown>): Promise<DomainEvent>;
9
+ getLastSequence(entityId: string): Promise<number>;
10
+ appendCas(type: string, entityId: string, payload: Record<string, unknown>, expectedSequence: number): Promise<DomainEvent | null>;
9
11
  list(entityId: string, opts?: {
10
12
  type?: string;
11
13
  limit?: number;
@@ -20,6 +20,43 @@ export class DrizzleDomainEventRepository {
20
20
  return { id, type, entityId, payload, sequence, emittedAt };
21
21
  });
22
22
  }
23
+ async getLastSequence(entityId) {
24
+ const row = this.db
25
+ .select({ maxSeq: sql `coalesce(max(${domainEvents.sequence}), 0)` })
26
+ .from(domainEvents)
27
+ .where(eq(domainEvents.entityId, entityId))
28
+ .get();
29
+ return row?.maxSeq ?? 0;
30
+ }
31
+ async appendCas(type, entityId, payload, expectedSequence) {
32
+ try {
33
+ return this.db.transaction((tx) => {
34
+ const currentRow = tx
35
+ .select({ maxSeq: sql `coalesce(max(${domainEvents.sequence}), 0)` })
36
+ .from(domainEvents)
37
+ .where(eq(domainEvents.entityId, entityId))
38
+ .get();
39
+ const currentSeq = currentRow?.maxSeq ?? 0;
40
+ if (currentSeq !== expectedSequence) {
41
+ return null;
42
+ }
43
+ const newSequence = expectedSequence + 1;
44
+ const id = randomUUID();
45
+ const emittedAt = Date.now();
46
+ tx.insert(domainEvents).values({ id, type, entityId, payload, sequence: newSequence, emittedAt }).run();
47
+ return { id, type, entityId, payload, sequence: newSequence, emittedAt };
48
+ });
49
+ }
50
+ catch (err) {
51
+ if (err instanceof Error &&
52
+ (("code" in err && err.code === "SQLITE_CONSTRAINT_UNIQUE") ||
53
+ ("code" in err && err.code === "23505") ||
54
+ err.message.includes("UNIQUE constraint failed"))) {
55
+ return null;
56
+ }
57
+ throw err;
58
+ }
59
+ }
23
60
  async list(entityId, opts) {
24
61
  const conditions = [eq(domainEvents.entityId, entityId)];
25
62
  if (opts?.type) {
@@ -374,6 +374,11 @@ export interface IDomainEventRepository {
374
374
  type?: string;
375
375
  limit?: number;
376
376
  }): Promise<DomainEvent[]>;
377
+ /** Get the current max sequence number for an entity (0 if no events exist). */
378
+ getLastSequence(entityId: string): Promise<number>;
379
+ /** Append a domain event only if the entity's current max sequence equals expectedSequence.
380
+ * Returns the event on success, or null if another writer won (unique constraint violation). */
381
+ appendCas(type: string, entityId: string, payload: Record<string, unknown>, expectedSequence: number): Promise<DomainEvent | null>;
377
382
  }
378
383
  /** Data-access contract for gate definitions and result recording. */
379
384
  export interface IGateRepository {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/defcon",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "engines": {