@evomap/evolver-core 2.0.0-beta.6 → 2.0.0-beta.8

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,10 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
3
3
  import { basename, dirname, join, resolve } from 'node:path';
4
4
  export function syncSleep(ms) {
5
5
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
6
6
  }
7
+ const MALFORMED_LOCK_GRACE_NS = 1000000000n;
7
8
  const ownedLocks = new Map();
9
+ const malformedLockObservations = new Map();
8
10
  export class LockTimeoutError extends Error {
9
11
  code = 'LOCK_TIMEOUT';
10
12
  constructor(_lockPath) {
@@ -60,7 +62,7 @@ function currentOwnerStat(path) {
60
62
  throw error;
61
63
  }
62
64
  }
63
- function readOwnerBounded(path) {
65
+ function readOwnerFileBounded(path) {
64
66
  const before = currentOwnerStat(path);
65
67
  if (before === null)
66
68
  return null;
@@ -95,12 +97,25 @@ function readOwnerBounded(path) {
95
97
  }
96
98
  if (total > MAX_LOCK_OWNER_BYTES)
97
99
  throw new UnsafeLockPathError('owner_too_large');
98
- return buffer.subarray(0, total).toString('utf8');
100
+ const settled = fstatSync(fd, { bigint: true });
101
+ assertRegularOwnerStat(settled);
102
+ const settledPath = currentOwnerStat(path);
103
+ if (settledPath === null
104
+ || settledPath.dev !== settled.dev
105
+ || settledPath.ino !== settled.ino
106
+ || settledPath.size !== settled.size
107
+ || settledPath.mtimeNs !== settled.mtimeNs) {
108
+ throw new UnsafeLockPathError('path_changed');
109
+ }
110
+ return { raw: buffer.subarray(0, total).toString('utf8'), stat: settled };
99
111
  }
100
112
  finally {
101
113
  closeSync(fd);
102
114
  }
103
115
  }
116
+ function readOwnerBounded(path) {
117
+ return readOwnerFileBounded(path)?.raw ?? null;
118
+ }
104
119
  /** True if pid is a live process (cross-platform via signal 0; EPERM = exists but owned by another user). */
105
120
  function pidAlive(pid) {
106
121
  if (!Number.isInteger(pid) || pid <= 0)
@@ -147,11 +162,19 @@ function parseOwner(raw) {
147
162
  return null;
148
163
  return parseJsonOwner(trimmed) ?? parseLegacyOwner(trimmed);
149
164
  }
150
- /** Owner recorded in the lock file; null if missing/unparseable. */
151
- function lockOwner(lockPath) {
165
+ /** Snapshot of the lock inode and its owner payload; null if the path disappeared. */
166
+ function lockSnapshot(lockPath) {
152
167
  try {
153
- const raw = readOwnerBounded(lockPath);
154
- return raw === null ? null : parseOwner(raw);
168
+ const read = readOwnerFileBounded(lockPath);
169
+ if (read === null)
170
+ return null;
171
+ return {
172
+ owner: parseOwner(read.raw),
173
+ dev: read.stat.dev,
174
+ ino: read.stat.ino,
175
+ size: read.stat.size,
176
+ mtimeNs: read.stat.mtimeNs,
177
+ };
155
178
  }
156
179
  catch (error) {
157
180
  // A valid owner can release and another waiter can acquire between lstat/open/fstat. Treat that snapshot as
@@ -161,6 +184,31 @@ function lockOwner(lockPath) {
161
184
  throw error;
162
185
  }
163
186
  }
187
+ function sameInode(left, right) {
188
+ return left !== null && right !== null && left.dev === right.dev && left.ino === right.ino;
189
+ }
190
+ function sameSnapshot(left, right) {
191
+ return sameInode(left, right)
192
+ && left?.size === right?.size
193
+ && left?.mtimeNs === right?.mtimeNs;
194
+ }
195
+ function forgetMalformedObservation(lockPath) {
196
+ malformedLockObservations.delete(lockKey(lockPath));
197
+ }
198
+ function reclaimableSnapshot(lockPath, snapshot) {
199
+ if (snapshot.owner !== null) {
200
+ forgetMalformedObservation(lockPath);
201
+ return snapshot.owner.pid !== process.pid && !pidAlive(snapshot.owner.pid);
202
+ }
203
+ const key = lockKey(lockPath);
204
+ const now = process.hrtime.bigint();
205
+ const observed = malformedLockObservations.get(key);
206
+ if (observed === undefined || !sameSnapshot(observed.snapshot, snapshot)) {
207
+ malformedLockObservations.set(key, { snapshot, firstSeenAtNs: now });
208
+ return false;
209
+ }
210
+ return now - observed.firstSeenAtNs >= MALFORMED_LOCK_GRACE_NS;
211
+ }
164
212
  function sameOwner(parsed, owner) {
165
213
  return parsed?.pid === owner.pid && parsed.token === owner.token;
166
214
  }
@@ -179,12 +227,68 @@ function removeFileIfExists(path) {
179
227
  throw error;
180
228
  }
181
229
  }
230
+ function pathExists(path) {
231
+ return existsSync(path);
232
+ }
233
+ function restoreMovedFile(lockPath, movedPath, moved) {
234
+ try {
235
+ linkSync(movedPath, lockPath);
236
+ removeFileIfExists(movedPath);
237
+ forgetMalformedObservation(lockPath);
238
+ return;
239
+ }
240
+ catch (error) {
241
+ if (!isErrno(error, 'EEXIST'))
242
+ throw error;
243
+ }
244
+ const current = lockSnapshot(lockPath);
245
+ if (sameInode(current, moved)) {
246
+ removeFileIfExists(movedPath);
247
+ forgetMalformedObservation(lockPath);
248
+ return;
249
+ }
250
+ throw new Error(`文件锁在安全回收期间被替换,已保留候选文件: ${movedPath}`);
251
+ }
252
+ function removeUnchangedLock(lockPath, expected, kind) {
253
+ const movedPath = sidecarPath(lockPath, kind);
254
+ try {
255
+ renameSync(lockPath, movedPath);
256
+ }
257
+ catch (error) {
258
+ if (isErrno(error, 'ENOENT'))
259
+ return false;
260
+ throw error;
261
+ }
262
+ const moved = lockSnapshot(movedPath);
263
+ if (!sameSnapshot(moved, expected)) {
264
+ if (moved !== null)
265
+ restoreMovedFile(lockPath, movedPath, moved);
266
+ return false;
267
+ }
268
+ removeFileIfExists(movedPath);
269
+ forgetMalformedObservation(lockPath);
270
+ return true;
271
+ }
272
+ function removeFileIfOwned(lockPath, owner) {
273
+ const snapshot = lockSnapshot(lockPath);
274
+ if (snapshot === null || !sameOwner(snapshot.owner, owner))
275
+ return false;
276
+ return removeUnchangedLock(lockPath, snapshot, 'released');
277
+ }
182
278
  function createLockFile(lockPath, owner, trackOwnership) {
279
+ const guardPath = mutationGuardPath(lockPath);
280
+ if (trackOwnership && pathExists(guardPath))
281
+ return false;
183
282
  for (let permissionAttempt = 0; permissionAttempt < 2; permissionAttempt++) {
184
283
  try {
185
284
  writeFileSync(lockPath, serializeOwner(owner), { flag: 'wx', mode: 0o600 });
285
+ if (trackOwnership && pathExists(guardPath)) {
286
+ removeFileIfOwned(lockPath, owner);
287
+ return false;
288
+ }
186
289
  if (trackOwnership)
187
290
  ownedLocks.set(lockKey(lockPath), { owner, releasePending: false });
291
+ forgetMalformedObservation(lockPath);
188
292
  return true;
189
293
  }
190
294
  catch (error) {
@@ -210,55 +314,25 @@ function tryAcquireMutationGuard(lockPath) {
210
314
  const guard = newOwner();
211
315
  if (createLockFile(guardPath, guard, false))
212
316
  return guard;
213
- const owner = lockOwner(guardPath);
214
- if (owner === null || owner.pid === process.pid || pidAlive(owner.pid))
317
+ const snapshot = lockSnapshot(guardPath);
318
+ if (snapshot === null || !reclaimableSnapshot(guardPath, snapshot))
319
+ return null;
320
+ if (!removeUnchangedLock(guardPath, snapshot, 'stale'))
215
321
  return null;
216
- const stalePath = sidecarPath(guardPath, 'stale');
217
- try {
218
- renameSync(guardPath, stalePath);
219
- }
220
- catch (error) {
221
- if (isErrno(error, 'ENOENT'))
222
- return null;
223
- throw error;
224
- }
225
- removeFileIfExists(stalePath);
226
322
  return createLockFile(guardPath, guard, false) ? guard : null;
227
323
  }
228
324
  function releaseMutationGuard(lockPath, guard) {
229
- const guardPath = mutationGuardPath(lockPath);
230
- if (!sameOwner(lockOwner(guardPath), guard))
231
- return;
232
- const releasedPath = sidecarPath(guardPath, 'released');
233
- try {
234
- renameSync(guardPath, releasedPath);
235
- }
236
- catch (error) {
237
- if (isErrno(error, 'ENOENT'))
238
- return;
239
- throw error;
240
- }
241
- removeFileIfExists(releasedPath);
325
+ removeFileIfOwned(mutationGuardPath(lockPath), guard);
242
326
  }
243
327
  function reclaimStaleLock(lockPath) {
244
328
  const guard = tryAcquireMutationGuard(lockPath);
245
329
  if (guard === null)
246
330
  return false;
247
331
  try {
248
- const owner = lockOwner(lockPath);
249
- if (owner === null || owner.pid === process.pid || pidAlive(owner.pid))
332
+ const snapshot = lockSnapshot(lockPath);
333
+ if (snapshot === null || !reclaimableSnapshot(lockPath, snapshot))
250
334
  return false;
251
- const stalePath = sidecarPath(lockPath, 'stale');
252
- try {
253
- renameSync(lockPath, stalePath);
254
- }
255
- catch (error) {
256
- if (isErrno(error, 'ENOENT'))
257
- return false;
258
- throw error;
259
- }
260
- removeFileIfExists(stalePath);
261
- return true;
335
+ return removeUnchangedLock(lockPath, snapshot, 'stale');
262
336
  }
263
337
  finally {
264
338
  releaseMutationGuard(lockPath, guard);
@@ -270,9 +344,11 @@ function reclaimStaleLock(lockPath) {
270
344
  * The lock file records the owner pid and token. If a waiter finds the lock held by a pid that is no longer
271
345
  * alive (the owner crashed without releaseLock), it reclaims the stale lock instead of spinning
272
346
  * until timeout — otherwise one crashed process would deadlock every future writer until the file
273
- * is removed by hand. Acquisition stays atomic (O_EXCL), and stale reclaim moves the old lock aside
274
- * under a mutation guard before deletion so waiters cannot delete each other's newly-created locks.
275
- * A live owner's lock (including this process's own) is never stolen.
347
+ * is removed by hand. Empty or truncated locks are reclaimed only after the same inode and contents
348
+ * remain malformed for a grace period, so a live creator can finish publishing its owner payload.
349
+ * Acquisition stays atomic (O_EXCL), and stale reclaim moves the old lock aside under a mutation
350
+ * guard, then verifies the inode snapshot before deletion. A live owner's lock (including this
351
+ * process's own) is never stolen.
276
352
  *
277
353
  * NOTE: still synchronous (blocks the event loop while waiting) by design — it guards short
278
354
  * synchronous critical sections (append-only writes).
@@ -291,12 +367,17 @@ export function acquireLock(lockPath, opts = {}) {
291
367
  if (createLockFile(lockPath, owner, true)) {
292
368
  return;
293
369
  }
294
- const current = lockOwner(lockPath);
295
- if (current !== null && current.pid !== process.pid && !pidAlive(current.pid)) {
370
+ const current = lockSnapshot(lockPath);
371
+ if (current !== null && reclaimableSnapshot(lockPath, current)) {
296
372
  if (!reclaimStaleLock(lockPath))
297
373
  syncSleep(waitMs);
298
374
  continue;
299
375
  }
376
+ if (current === null && pathExists(mutationGuardPath(lockPath))) {
377
+ const guard = tryAcquireMutationGuard(lockPath);
378
+ if (guard !== null)
379
+ releaseMutationGuard(lockPath, guard);
380
+ }
300
381
  syncSleep(waitMs);
301
382
  }
302
383
  throw new LockTimeoutError();
@@ -14,13 +14,26 @@ export interface CoreCall {
14
14
  fn: string;
15
15
  args: Value[];
16
16
  }
17
- export interface ScriptStep {
17
+ export type WorkflowErrorClass = 'transient' | 'permanent' | 'safety' | 'unknown';
18
+ export declare function containsWorkflowSensitiveText(value: string): boolean;
19
+ export interface WorkflowRetryPolicy {
20
+ maxAttempts: number;
21
+ initialDelayMs?: number;
22
+ maxDelayMs?: number;
23
+ multiplier?: number;
24
+ }
25
+ export interface DurableStepOptions {
26
+ /** Agent steps default to non-idempotent; script steps default to idempotent. */
27
+ idempotency?: 'idempotent' | 'non_idempotent';
28
+ retry?: WorkflowRetryPolicy;
29
+ }
30
+ export interface ScriptStep extends DurableStepOptions {
18
31
  id: string;
19
32
  kind: 'script';
20
33
  setOutput: string;
21
34
  call: CoreCall;
22
35
  }
23
- export interface AgentStep {
36
+ export interface AgentStep extends DurableStepOptions {
24
37
  id: string;
25
38
  kind: 'agent';
26
39
  prompt: string;
@@ -42,8 +55,16 @@ export interface IfStep {
42
55
  then: WorkflowStep[];
43
56
  else?: WorkflowStep[];
44
57
  }
45
- export type WorkflowStep = ScriptStep | AgentStep | ForeachStep | IfStep;
58
+ /** Durable human gate. Execution stops before subsequent steps until an operator approves or rejects it. */
59
+ export interface ApprovalStep {
60
+ id: string;
61
+ kind: 'approval';
62
+ label?: string;
63
+ }
64
+ export type WorkflowStep = ScriptStep | AgentStep | ForeachStep | IfStep | ApprovalStep;
46
65
  export interface WorkflowSpec {
66
+ /** Stable caller-supplied identity. If omitted, the durable runtime derives one from the definition. */
67
+ workflowId?: string;
47
68
  name: string;
48
69
  input?: Record<string, unknown>;
49
70
  steps: WorkflowStep[];
@@ -3,6 +3,10 @@
3
3
  * 表达式表示为**结构化对象**(非字符串解析): 引用 Ref + 白名单 core 调用, 安全 by-construction.
4
4
  */
5
5
  export function isRef(v) { return typeof v === 'object' && v !== null && 'ref' in v; }
6
+ const WORKFLOW_SENSITIVE_TEXT_RE = /(?:\bBearer\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9_-]{12,}|\b(?:gh[oprsu]|npm)_[A-Za-z0-9_-]{8,}|\bgithub_pat_[A-Za-z0-9_]{8,}|\b(?:AKIA|ASIA)[0-9A-Z]{16}\b|\bAIza[0-9A-Za-z_-]{35}\b|-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:password|passwd|secret|token|api[_-]?key|accountkey)\s*[:=]\s*\S+)/i;
7
+ export function containsWorkflowSensitiveText(value) {
8
+ return WORKFLOW_SENSITIVE_TEXT_RE.test(value);
9
+ }
6
10
  export const CORE_WHITELIST = {
7
11
  list_len: (a) => (Array.isArray(a) ? a.length : 0),
8
12
  filter_by_mask: (items, mask) => (Array.isArray(items) && Array.isArray(mask) ? items.filter((_, i) => Boolean(mask[i])) : []),
@@ -4,8 +4,12 @@ export interface WorkflowContext {
4
4
  steps: Record<string, unknown>;
5
5
  item?: unknown;
6
6
  }
7
+ export interface AgentBridgeOptions {
8
+ /** Cooperative cancellation for the current durable attempt. */
9
+ signal?: AbortSignal;
10
+ }
7
11
  /** agent step 桥(注入 runtime; 孵化期可注 stub). */
8
- export type AgentBridge = (prompt: string, ctx: WorkflowContext) => Promise<unknown> | unknown;
12
+ export type AgentBridge = (prompt: string, ctx: WorkflowContext, options?: AgentBridgeOptions) => Promise<unknown> | unknown;
9
13
  export interface WorkflowEngineDeps {
10
14
  core?: Record<string, CoreFn>;
11
15
  agent: AgentBridge;
@@ -68,6 +68,9 @@ export class WorkflowEngine {
68
68
  await this.runSteps(branch, ctx);
69
69
  return;
70
70
  }
71
+ case 'approval': {
72
+ throw new WorkflowError('approval steps require DurableWorkflowRuntime');
73
+ }
71
74
  case 'foreach': {
72
75
  const items = resolveRef(step.over.ref, ctx);
73
76
  if (!Array.isArray(items))
@@ -1,2 +1,4 @@
1
1
  export * from './dsl.js';
2
- export * from './engine.js';
2
+ export * from './engine.js';
3
+ export * from './stateStore.js';
4
+ export * from './runtime.js';
@@ -1,2 +1,4 @@
1
1
  export * from './dsl.js';
2
- export * from './engine.js';
2
+ export * from './engine.js';
3
+ export * from './stateStore.js';
4
+ export * from './runtime.js';
@@ -0,0 +1,110 @@
1
+ import { type CoreFn, type WorkflowErrorClass, type WorkflowSpec } from './dsl.js';
2
+ import type { AgentBridge, WorkflowContext } from './engine.js';
3
+ import { WorkflowStateError, WorkflowStateStore, type WorkflowControlState, type WorkflowOperatorOptions, type WorkflowRunState, type WorkflowStateStoreOptions, type WorkflowStepState } from './stateStore.js';
4
+ export declare const MAX_WORKFLOW_DEFINITION_STEPS = 256;
5
+ export declare const MAX_WORKFLOW_NESTING_DEPTH = 16;
6
+ export declare const MAX_WORKFLOW_FOREACH_ITEMS = 1000;
7
+ export interface WorkflowRuntimeDeps {
8
+ agent: AgentBridge;
9
+ stateDir?: string;
10
+ core?: Record<string, CoreFn>;
11
+ now?: () => number;
12
+ sleep?: (ms: number) => Promise<void>;
13
+ classifyError?: (error: unknown) => WorkflowErrorClass;
14
+ generateRunId?: () => string;
15
+ maxConcurrentRuns?: number;
16
+ maxQueuedRuns?: number;
17
+ maxStepExecutions?: number;
18
+ /** Test/embedding seam invoked only after a durable checkpoint has completed. */
19
+ afterCheckpoint?: (state: Readonly<WorkflowRunState>) => void | Promise<void>;
20
+ /** Synchronous fault-injection seam invoked under the commit lock; callbacks must not re-enter the store. */
21
+ onTransactionPhase?: WorkflowStateStoreOptions['onTransactionPhase'];
22
+ /** Bounded startup-recovery diagnostic; raw errors are never exposed. */
23
+ onRecoveryFailure?: (failure: WorkflowRecoveryFailure) => void;
24
+ lock?: {
25
+ maxTries?: number;
26
+ waitMs?: number;
27
+ };
28
+ }
29
+ export interface WorkflowRecoveryFailure {
30
+ runId: string;
31
+ code: WorkflowStateError['code'] | 'RUN_FAILED' | 'RECOVERY_FAILED';
32
+ }
33
+ export interface WorkflowRunResult {
34
+ runId: string;
35
+ workflowId: string;
36
+ status: WorkflowRunState['status'];
37
+ output: unknown;
38
+ ctx: WorkflowContext;
39
+ }
40
+ export declare class WorkflowRuntimeError extends Error {
41
+ readonly code: 'SENSITIVE_STATE' | 'INVALID_STATE' | 'UNSUPPORTED_PARALLELISM' | 'RUN_NOT_RESUMABLE' | 'INVALID_RETRY' | 'INVALID_CONCURRENCY' | 'RESOURCE_LIMIT';
42
+ constructor(message: string, code: 'SENSITIVE_STATE' | 'INVALID_STATE' | 'UNSUPPORTED_PARALLELISM' | 'RUN_NOT_RESUMABLE' | 'INVALID_RETRY' | 'INVALID_CONCURRENCY' | 'RESOURCE_LIMIT');
43
+ }
44
+ export declare class WorkflowRunFailedError extends Error {
45
+ readonly runId: string;
46
+ readonly status: 'failed' | 'unsafe_to_resume';
47
+ readonly errorClass: WorkflowStepState['lastErrorClass'];
48
+ constructor(runId: string, status: 'failed' | 'unsafe_to_resume', errorClass: WorkflowStepState['lastErrorClass']);
49
+ }
50
+ export declare class ClassifiedWorkflowError extends Error {
51
+ readonly errorClass: WorkflowErrorClass;
52
+ constructor(errorClass: WorkflowErrorClass, message?: string);
53
+ }
54
+ export declare function defaultWorkflowStateDir(): string;
55
+ export declare function deriveWorkflowId(spec: WorkflowSpec): string;
56
+ export declare function assertValidWorkflowSpec(value: unknown): asserts value is WorkflowSpec;
57
+ export declare class DurableWorkflowRuntime {
58
+ private readonly deps;
59
+ readonly store: WorkflowStateStore;
60
+ private readonly core;
61
+ private readonly now;
62
+ private readonly sleep;
63
+ private readonly classifyError;
64
+ private readonly generateRunId;
65
+ private readonly scheduler;
66
+ private readonly maxConcurrentRuns;
67
+ private readonly maxStepExecutions;
68
+ private readonly inFlight;
69
+ constructor(deps: WorkflowRuntimeDeps);
70
+ start(spec: WorkflowSpec, options?: {
71
+ runId?: string;
72
+ } | string): Promise<WorkflowRunResult>;
73
+ resume(runId: string, options?: WorkflowOperatorOptions): Promise<WorkflowRunResult>;
74
+ operatorResume(runId: string, options: WorkflowOperatorOptions): Promise<WorkflowRunResult>;
75
+ pause(runId: string, options: WorkflowOperatorOptions): Promise<WorkflowRunResult>;
76
+ cancel(runId: string, options: WorkflowOperatorOptions): Promise<WorkflowRunResult>;
77
+ approve(runId: string, gateId: string, options: WorkflowOperatorOptions): Promise<WorkflowRunResult>;
78
+ reject(runId: string, gateId: string, options: WorkflowOperatorOptions): Promise<WorkflowRunResult>;
79
+ status(runId: string): WorkflowRunState;
80
+ history(runId: string): ReturnType<WorkflowStateStore['readHistory']>;
81
+ recoverPending(): Promise<WorkflowRunResult[]>;
82
+ protected isRecoveryCandidate(state: WorkflowRunState, control: WorkflowControlState): boolean;
83
+ protected recoverRuns(shouldRecover: (runId: string) => boolean | Promise<boolean>, reportFailure: (runId: string, error: unknown) => void): Promise<WorkflowRunResult[]>;
84
+ private scheduleRecoveryRun;
85
+ private scheduleRun;
86
+ private resumeLocked;
87
+ private withLockedRun;
88
+ private withConcurrencySlot;
89
+ private validateResumeStateOrFail;
90
+ private execute;
91
+ private runSteps;
92
+ private runStep;
93
+ private lastStepOutput;
94
+ private failState;
95
+ private runApproval;
96
+ private runLeaf;
97
+ private runAgentAttempt;
98
+ private observeCancellation;
99
+ private checkpointCompletion;
100
+ private enforceControlBoundary;
101
+ private finalizeCancellation;
102
+ private checkpoint;
103
+ private safetyCheckpoint;
104
+ private ensureApprovalDecisionHistory;
105
+ private appendHistoryUnlessUnavailable;
106
+ private historyEvent;
107
+ private timestamp;
108
+ private result;
109
+ private reportRecoveryFailure;
110
+ }