@ornexus/neocortex 4.60.68 → 4.60.69

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.
Files changed (232) hide show
  1. package/dist/release-source.json +1 -1
  2. package/dist/sbom.cdx.json +1194 -1194
  3. package/install.ps1 +1 -1
  4. package/install.sh +1 -1
  5. package/package.json +2 -2
  6. package/packages/client/dist/adapters/adapter-registry.js +1 -108
  7. package/packages/client/dist/adapters/antigravity-adapter.js +2 -77
  8. package/packages/client/dist/adapters/claude-code-adapter.js +3 -79
  9. package/packages/client/dist/adapters/codex-adapter.js +2 -80
  10. package/packages/client/dist/adapters/cursor-adapter.js +4 -115
  11. package/packages/client/dist/adapters/gemini-adapter.js +2 -71
  12. package/packages/client/dist/adapters/grok-adapter.js +2 -42
  13. package/packages/client/dist/adapters/index.js +1 -22
  14. package/packages/client/dist/adapters/platform-detector.js +1 -212
  15. package/packages/client/dist/adapters/target-adapter.js +0 -12
  16. package/packages/client/dist/adapters/vscode-adapter.js +2 -72
  17. package/packages/client/dist/agent/refresh-stubs.js +2 -417
  18. package/packages/client/dist/agent/update-agent-yaml.js +1 -102
  19. package/packages/client/dist/agent/update-description.js +1 -251
  20. package/packages/client/dist/cache/crypto-utils.js +1 -78
  21. package/packages/client/dist/cache/encrypted-cache.js +1 -108
  22. package/packages/client/dist/cache/in-memory-asset-cache.js +1 -73
  23. package/packages/client/dist/cache/index.js +1 -13
  24. package/packages/client/dist/cache/protected-pi-boundary.js +1 -44
  25. package/packages/client/dist/checkpoint/checkpoint-client-reader.js +2 -127
  26. package/packages/client/dist/checkpoint/index.js +1 -11
  27. package/packages/client/dist/checkpoint/shared-checkpoint-types.js +1 -32
  28. package/packages/client/dist/cli.js +3 -290
  29. package/packages/client/dist/commands/activate.js +8 -394
  30. package/packages/client/dist/commands/architecture-answers.js +7 -274
  31. package/packages/client/dist/commands/architecture-memory-terminal.js +4 -253
  32. package/packages/client/dist/commands/architecture-non-ui-continuation.js +1 -100
  33. package/packages/client/dist/commands/architecture-root-binding.js +1 -51
  34. package/packages/client/dist/commands/cache-status.js +2 -112
  35. package/packages/client/dist/commands/config.js +1 -88
  36. package/packages/client/dist/commands/doctor.js +2 -635
  37. package/packages/client/dist/commands/invoke.d.ts +6 -0
  38. package/packages/client/dist/commands/invoke.js +142 -7140
  39. package/packages/client/dist/commands/legacy-epic-terminal.js +1 -71
  40. package/packages/client/dist/commands/legacy-story-branch.js +1 -96
  41. package/packages/client/dist/commands/legacy-story-pr.js +1 -276
  42. package/packages/client/dist/commands/mutation-qa-checkpoint.js +5 -285
  43. package/packages/client/dist/commands/mutation-qa-gate.js +1 -54
  44. package/packages/client/dist/commands/mutation-qa-git.js +1 -41
  45. package/packages/client/dist/commands/mutation-qa-policy.js +1 -102
  46. package/packages/client/dist/commands/mutation-qa-run.js +3 -219
  47. package/packages/client/dist/commands/mutation-qa-verdict.js +1 -149
  48. package/packages/client/dist/commands/mutation-qa.js +1 -220
  49. package/packages/client/dist/commands/native-agent.js +2 -136
  50. package/packages/client/dist/commands/planning-diagnosis.js +1 -43
  51. package/packages/client/dist/commands/planning-generation.js +1 -53
  52. package/packages/client/dist/commands/planning-memory.js +1 -40
  53. package/packages/client/dist/commands/planning-research.js +1 -42
  54. package/packages/client/dist/commands/private-session.js +1 -75
  55. package/packages/client/dist/commands/property-qa.js +1 -139
  56. package/packages/client/dist/commands/qa-control.js +1 -16
  57. package/packages/client/dist/commands/qa-scan-browser-launch.js +1 -55
  58. package/packages/client/dist/commands/qa-scan-browser-route.js +1 -28
  59. package/packages/client/dist/commands/qa-scan-capture.js +5 -663
  60. package/packages/client/dist/commands/qa-scan-discovery.js +3 -376
  61. package/packages/client/dist/commands/qa-scan-review.js +5 -826
  62. package/packages/client/dist/commands/qa-synthetic-backend-capture.js +1 -266
  63. package/packages/client/dist/commands/qa-synthetic-backend-review.js +1 -297
  64. package/packages/client/dist/commands/qa-synthetic-backend.js +2 -340
  65. package/packages/client/dist/commands/qa-synthetic-broker-port.js +1 -85
  66. package/packages/client/dist/commands/qa-synthetic-browser.js +1 -39
  67. package/packages/client/dist/commands/refactor-characterization.js +21 -671
  68. package/packages/client/dist/commands/refactor-commit.js +13 -399
  69. package/packages/client/dist/commands/refactor-control.js +3 -142
  70. package/packages/client/dist/commands/refactor-cpp-assertions.js +3 -93
  71. package/packages/client/dist/commands/refactor-review.js +5 -283
  72. package/packages/client/dist/commands/refactor-root.js +7 -588
  73. package/packages/client/dist/commands/refresh-memory.js +3 -156
  74. package/packages/client/dist/commands/shared-architecture-non-ui.js +1 -24
  75. package/packages/client/dist/commands/story-checkpoint.js +1 -171
  76. package/packages/client/dist/config/profile-directory.js +1 -13
  77. package/packages/client/dist/config/resolver-selection.js +1 -278
  78. package/packages/client/dist/config/secure-config.js +12 -281
  79. package/packages/client/dist/config.js +2 -223
  80. package/packages/client/dist/constants.js +1 -25
  81. package/packages/client/dist/context/context-collector.js +2 -222
  82. package/packages/client/dist/context/context-sanitizer.js +1 -145
  83. package/packages/client/dist/continuity/autonomous-governance.js +1 -285
  84. package/packages/client/dist/continuity/continuity-client-state-store.js +1 -1587
  85. package/packages/client/dist/continuity/invoke-hooks.js +1 -129
  86. package/packages/client/dist/continuity/migrations/001-initial-schema.js +2 -12
  87. package/packages/client/dist/continuity/migrations/002-processing-output-summary-indexes.js +2 -12
  88. package/packages/client/dist/continuity/migrations/003-headless-execution-attempts.js +2 -12
  89. package/packages/client/dist/continuity/migrations/004-outbox-delivery-state.js +2 -12
  90. package/packages/client/dist/continuity/migrations/005-bounded-headless-queue.js +2 -12
  91. package/packages/client/dist/continuity/migrations/006-native-agent-dispatches.js +2 -10
  92. package/packages/client/dist/continuity/native-agent-store.js +1 -69
  93. package/packages/client/dist/continuity/native-agent-types.js +1 -90
  94. package/packages/client/dist/continuity/outbox.js +1 -117
  95. package/packages/client/dist/continuity/runner-bootstrap-policy.js +1 -172
  96. package/packages/client/dist/continuity/sqlite-store.js +81 -2536
  97. package/packages/client/dist/effects/invoke-effect-gateways.js +1 -62
  98. package/packages/client/dist/errors/error-messages.js +2 -228
  99. package/packages/client/dist/errors/frontmatter-diagnostics.js +1 -125
  100. package/packages/client/dist/evidence/collector.js +1 -347
  101. package/packages/client/dist/evidence/index.js +1 -1
  102. package/packages/client/dist/evidence/types.js +0 -2
  103. package/packages/client/dist/fs/atomic-state-replace.js +1 -152
  104. package/packages/client/dist/generated/command-registry.generated.js +1 -4076
  105. package/packages/client/dist/generated/public-projections.generated.js +1 -149
  106. package/packages/client/dist/git/direct-yolo-c01-retry.js +4 -138
  107. package/packages/client/dist/git/direct-yolo-checkpoint.js +4 -1234
  108. package/packages/client/dist/git/git-lifecycle-step-ids.js +1 -14
  109. package/packages/client/dist/git/git-lifecycle-store.js +9 -1938
  110. package/packages/client/dist/git/legacy-story-branch-checkpoint.js +9 -259
  111. package/packages/client/dist/git/legacy-story-branch-setup.js +4 -348
  112. package/packages/client/dist/git/legacy-story-pr.js +70 -3538
  113. package/packages/client/dist/git/secure-filesystem-boundary.js +5 -182
  114. package/packages/client/dist/graph-retrieval/pre-command-hook.js +1 -284
  115. package/packages/client/dist/graph-retrieval/shared-graph-retrieval-contract.js +1 -237
  116. package/packages/client/dist/i18n/first-run.js +2 -60
  117. package/packages/client/dist/index.js +1 -47
  118. package/packages/client/dist/invocation/invoke-input.js +1 -24
  119. package/packages/client/dist/license/index.js +1 -5
  120. package/packages/client/dist/license/license-client.js +1 -304
  121. package/packages/client/dist/machine/fingerprint.js +2 -163
  122. package/packages/client/dist/machine/index.js +1 -5
  123. package/packages/client/dist/memory/project-memory-path-scope.js +1 -62
  124. package/packages/client/dist/memory/project-memory-writer.js +40 -1007
  125. package/packages/client/dist/memory/shared-project-memory-types.js +2 -253
  126. package/packages/client/dist/migrations/project-identity-capability.js +1 -50
  127. package/packages/client/dist/migrations/project-identity.js +7 -493
  128. package/packages/client/dist/migrations/state-json.js +4 -344
  129. package/packages/client/dist/policy/architecture-policy.js +2 -184
  130. package/packages/client/dist/policy/index.js +1 -7
  131. package/packages/client/dist/policy/shared-policy-types.js +0 -38
  132. package/packages/client/dist/policy/subagent-approval-policy.js +1 -55
  133. package/packages/client/dist/private-execution/clean-baseline.js +2 -279
  134. package/packages/client/dist/private-execution/codex-executor.js +4 -574
  135. package/packages/client/dist/private-execution/codex-host-runtime.js +1 -137
  136. package/packages/client/dist/private-execution/codex-lifecycle-guard.js +1 -97
  137. package/packages/client/dist/private-execution/codex-scoped-permissions.js +1 -128
  138. package/packages/client/dist/private-execution/delivery.js +13 -410
  139. package/packages/client/dist/private-execution/diagnosis-root-review.js +2 -294
  140. package/packages/client/dist/private-execution/frozen-handoff.js +6 -19
  141. package/packages/client/dist/private-execution/implementation-validation.js +2 -106
  142. package/packages/client/dist/private-execution/isolated-admission.js +23 -1432
  143. package/packages/client/dist/private-execution/isolated-session.js +5 -725
  144. package/packages/client/dist/private-execution/isolated-workspace.js +1 -122
  145. package/packages/client/dist/private-execution/pact-consumer-runtime.js +3 -409
  146. package/packages/client/dist/private-execution/pact-test-source.js +1 -258
  147. package/packages/client/dist/private-execution/pact-toolchain.js +3 -290
  148. package/packages/client/dist/private-execution/pact-write-set.js +1 -172
  149. package/packages/client/dist/private-execution/planning-diagnosis-checkpoint.js +5 -406
  150. package/packages/client/dist/private-execution/planning-generation-checkpoint.js +5 -462
  151. package/packages/client/dist/private-execution/planning-memory-checkpoint.js +3 -139
  152. package/packages/client/dist/private-execution/planning-research-checkpoint.js +4 -325
  153. package/packages/client/dist/private-execution/private-report-store.js +1 -42
  154. package/packages/client/dist/private-execution/property-test-candidate.js +1 -103
  155. package/packages/client/dist/private-execution/property-test-choices.js +3 -147
  156. package/packages/client/dist/private-execution/property-test-plan.js +7 -271
  157. package/packages/client/dist/private-execution/property-test-review.js +4 -258
  158. package/packages/client/dist/private-execution/property-test-runtime.js +9 -372
  159. package/packages/client/dist/private-execution/property-test-source.js +1 -139
  160. package/packages/client/dist/private-execution/root-checkpoint.js +1 -80
  161. package/packages/client/dist/private-execution/root-memory-git-evidence.js +3 -135
  162. package/packages/client/dist/private-execution/root-memory-recovery.js +1 -93
  163. package/packages/client/dist/private-execution/root-memory-request.js +1 -29
  164. package/packages/client/dist/private-execution/root-memory-scope.js +1 -178
  165. package/packages/client/dist/private-execution/root-memory-writer.js +1 -102
  166. package/packages/client/dist/private-execution/session-lease.js +1 -63
  167. package/packages/client/dist/private-execution/shared-private-admission-types.js +1 -50
  168. package/packages/client/dist/private-execution/shared-project-validation.js +1 -66
  169. package/packages/client/dist/private-execution/shared-root-memory-types.js +1 -61
  170. package/packages/client/dist/private-execution/ux-source-validation.js +1 -372
  171. package/packages/client/dist/private-execution/ux-write-set.js +2 -191
  172. package/packages/client/dist/resilience/circuit-breaker.js +1 -170
  173. package/packages/client/dist/resilience/degradation-manager.js +1 -164
  174. package/packages/client/dist/resilience/freshness-indicator.js +1 -100
  175. package/packages/client/dist/resilience/index.js +1 -8
  176. package/packages/client/dist/resilience/recovery-detector.js +1 -74
  177. package/packages/client/dist/resolvers/asset-resolver.js +0 -13
  178. package/packages/client/dist/resolvers/local-resolver.js +8 -255
  179. package/packages/client/dist/resolvers/remote-resolver.js +1 -233
  180. package/packages/client/dist/runner/binary.js +1 -404
  181. package/packages/client/dist/runner/cli.js +17 -1571
  182. package/packages/client/dist/runner/headless/adapters.js +1 -49
  183. package/packages/client/dist/runner/headless/claude-code-adapter.js +1 -79
  184. package/packages/client/dist/runner/headless/continuous-story-workspace.js +11 -1628
  185. package/packages/client/dist/runner/headless/epic-git-reconciliation-inventory.js +1 -69
  186. package/packages/client/dist/runner/headless/epic-merge-candidate-qa.js +2 -110
  187. package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.js +8 -1236
  188. package/packages/client/dist/runner/headless/epic-terminal-git-runner.js +2 -318
  189. package/packages/client/dist/runner/headless/fake-adapter.js +1 -62
  190. package/packages/client/dist/runner/headless/managed-profile.js +1 -132
  191. package/packages/client/dist/runner/headless/opencode-adapter.js +2 -145
  192. package/packages/client/dist/runner/headless/prepared-story-bundle.js +1 -43
  193. package/packages/client/dist/runner/headless/resolver.js +1 -231
  194. package/packages/client/dist/runner/headless/run-yolo.js +1 -885
  195. package/packages/client/dist/runner/headless/run-yoloop.js +2 -2574
  196. package/packages/client/dist/runner/headless/types.js +2 -574
  197. package/packages/client/dist/runner/local-console.js +13 -396
  198. package/packages/client/dist/runner/portal-sync-worker.js +1 -1487
  199. package/packages/client/dist/runner/project-identity.js +1 -106
  200. package/packages/client/dist/runner/scheduler.js +7 -995
  201. package/packages/client/dist/runner-cli.js +2 -18
  202. package/packages/client/dist/state/invocation-shape.js +1 -13
  203. package/packages/client/dist/state/project-state-snapshot.js +1 -1677
  204. package/packages/client/dist/state/state-json-repair.js +3 -785
  205. package/packages/client/dist/state/structural-dag-projection.js +1 -118
  206. package/packages/client/dist/telemetry/index.js +1 -5
  207. package/packages/client/dist/telemetry/offline-queue.js +1 -131
  208. package/packages/client/dist/tier/index.js +1 -5
  209. package/packages/client/dist/tier/tier-aware-client.js +1 -405
  210. package/packages/client/dist/types/index.js +1 -38
  211. package/packages/client/dist/version.js +1 -27
  212. package/packages/client/dist/yolo/change-evidence-collector.js +1 -126
  213. package/packages/client/dist/yolo/invoke-envelope-capability.js +1 -62
  214. package/packages/client/dist/yolo/spec-envelope-collector.js +5 -185
  215. package/packages/client/dist/yoloop/discovery-hook.js +2 -300
  216. package/packages/client/dist/yoloop/index.js +1 -8
  217. package/packages/client/dist/yoloop/invoke-hooks.js +12 -669
  218. package/packages/client/dist/yoloop/shared-discover-epics.js +1 -1007
  219. package/packages/client/dist/yoloop/shared-yoloop-types.js +1 -33
  220. package/packages/client/dist/yoloop/target-normalization.js +1 -180
  221. package/packages/client/dist/yoloop/yoloop-client-state-store.js +1 -1723
  222. package/targets-stubs/antigravity/gemini.md +1 -1
  223. package/targets-stubs/antigravity/skill/SKILL.md +1 -1
  224. package/targets-stubs/claude-code/neocortex-root.agent.yaml +1 -1
  225. package/targets-stubs/claude-code/neocortex-root.md +2 -2
  226. package/targets-stubs/claude-code/neocortex.agent.yaml +1 -1
  227. package/targets-stubs/claude-code/neocortex.md +2 -2
  228. package/targets-stubs/codex/AGENTS.md +1 -1
  229. package/targets-stubs/cursor/agent.md +2 -2
  230. package/targets-stubs/gemini-cli/agent.md +2 -2
  231. package/targets-stubs/opencode/neocortex-root.md +1 -1
  232. package/targets-stubs/vscode/neocortex.agent.md +2 -2
@@ -1,1723 +1 @@
1
- /**
2
- * Epic P101.06 -- YoloopClientStateStore.
3
- *
4
- * Client-side counterpart of the (soon-to-be-deleted) server YoloopStateStore.
5
- * Owns `.neocortex/yoloop.lock` and `.neocortex/yoloop-state.json` in the
6
- * user's local project workspace.
7
- *
8
- * The client is the ONLY filesystem writer for yoloop state after P101
9
- * lands. The server is pure: it reads state echoed by the client via
10
- * `stateSnapshot.yoloop` and returns `metadata.yoloopStateUpdate` with an
11
- * operation log the client applies here.
12
- *
13
- * Atomicity guarantees (P96.03):
14
- * - acquireLock uses fs.open(... 'wx') -- POSIX exclusive create
15
- * - writeState uses tmp + rename (POSIX atomic rename)
16
- *
17
- * Fail-open philosophy (P61.04 / P66.01):
18
- * - chmod / ACL failure -> warn log, NEVER blocks operation
19
- * - release on missing lock is idempotent (no-op)
20
- * - readAll returns defaults on any I/O or parse error
21
- *
22
- * Cross-platform: Unix chmod + Windows icacls via existing
23
- * setSecureFilePermissions helper from secure-config.ts.
24
- */
25
- import { promises as fs } from 'node:fs';
26
- import { createHash } from 'node:crypto';
27
- import { join, dirname } from 'node:path';
28
- import { hostname } from 'node:os';
29
- import { setSecureFilePermissions, setSecureDirPermissions } from '../config/secure-config.js';
30
- import { RECOVERY_HISTORY_CAP } from './shared-yoloop-types.js';
31
- const NEOCORTEX_DIR = '.neocortex';
32
- const STATE_FILE = 'yoloop-state.json';
33
- const LOCK_FILE = 'yoloop.lock';
34
- const MUTATION_LOCK_FILE = 'yoloop-state.mutation.lock';
35
- const ROLLOVER_MANIFEST_FILE = 'yoloop-session-rollover.json';
36
- const SUCCESSOR_TRANSITION_MANIFEST_FILE = 'yoloop-successor-transition.json';
37
- const DEFAULT_LOCK_LEASE_MS = 6 * 60 * 60 * 1000;
38
- const MUTATION_LOCK_STALE_MS = 30_000;
39
- const MUTATION_LOCK_RETRIES = 200;
40
- export const YOLOOP_PERSISTENCE_CAPABILITIES = Object.freeze([
41
- 'rollover-session-v1',
42
- 'successor-transition-v1',
43
- 'continuation-replan-v1',
44
- ]);
45
- const NOOP_LOGGER = {
46
- warn: () => { },
47
- info: () => { },
48
- error: () => { },
49
- };
50
- /** Build default counters for a fresh project (no state file present). */
51
- export function createDefaultCounters(nowIso) {
52
- const dateOnly = nowIso.slice(0, 10);
53
- return {
54
- version: '1.1',
55
- lastResetAt: nowIso,
56
- storiesRunToday: 0,
57
- iterationsToday: 0,
58
- iterationsTodayDate: dateOnly,
59
- tokensUsedToday: 0,
60
- lastRunAt: null,
61
- coolDownUntil: null,
62
- totalRunsAllTime: 0,
63
- totalStoriesAllTime: 0,
64
- currentSession: null,
65
- continuationPolicy: 'p205-finite-resume-v2',
66
- pendingContinuationAuthorities: [],
67
- continuationReceipts: [],
68
- continuationRecoveryReceipts: [],
69
- };
70
- }
71
- /** Build default state envelope (no lock, default counters). */
72
- export function createDefaultState(nowIso) {
73
- return {
74
- lock: null,
75
- state: createDefaultCounters(nowIso),
76
- capabilities: YOLOOP_PERSISTENCE_CAPABILITIES,
77
- };
78
- }
79
- export class YoloopClientStateStore {
80
- stateDir;
81
- statePath;
82
- lockPath;
83
- mutationLockPath;
84
- rolloverManifestPath;
85
- successorTransitionManifestPath;
86
- logger;
87
- constructor(projectRoot, logger = NOOP_LOGGER) {
88
- this.stateDir = join(projectRoot, NEOCORTEX_DIR);
89
- this.statePath = join(this.stateDir, STATE_FILE);
90
- this.lockPath = join(this.stateDir, LOCK_FILE);
91
- this.mutationLockPath = join(this.stateDir, MUTATION_LOCK_FILE);
92
- this.rolloverManifestPath = join(this.stateDir, ROLLOVER_MANIFEST_FILE);
93
- this.successorTransitionManifestPath = join(this.stateDir, SUCCESSOR_TRANSITION_MANIFEST_FILE);
94
- this.logger = logger;
95
- }
96
- // ── Public read/apply API (P101.06 AC3 + AC4) ───────────────────────
97
- /**
98
- * Read lock + state in a single call. Used by invoke.ts pre-request hook
99
- * to populate `stateSnapshot.yoloop` for the server. Handles ENOENT
100
- * gracefully by returning default state.
101
- */
102
- async readAll(nowIso) {
103
- const now = nowIso ?? new Date().toISOString();
104
- await this.cleanupOrphanTempFiles();
105
- await this.recoverSessionRolloverIfPresent(now);
106
- await this.recoverSuccessorTransitionIfPresent(now);
107
- const [rawLock, state] = await Promise.all([this.readLock(), this.readState(now)]);
108
- const lock = await this.prepareLockForSnapshot(rawLock, now);
109
- return { lock, state, capabilities: YOLOOP_PERSISTENCE_CAPABILITIES };
110
- }
111
- /**
112
- * Apply server-provided operations in order. Called by invoke.ts
113
- * post-response hook. Operations are applied atomically per-op; if one
114
- * fails, subsequent ops are NOT applied and the caller (invoke.ts)
115
- * decides whether to log-and-continue or abort.
116
- *
117
- * Contract: partial application is safe because each operation is a
118
- * single atomic filesystem mutation. Next invoke's readAll() reconciles.
119
- */
120
- async applyUpdate(update, nowIso) {
121
- const now = nowIso ?? new Date().toISOString();
122
- await this.ensureDir();
123
- await this.recoverSessionRolloverIfPresent(now);
124
- await this.recoverSuccessorTransitionIfPresent(now);
125
- for (const op of update.operations) {
126
- await this.applyOperation(op, now);
127
- }
128
- }
129
- /**
130
- * Local typed lifecycle-control API. Server transitions and local
131
- * pause/resume/cancel requests share this serialized CAS boundary.
132
- */
133
- async requestRunTransition(operation, nowIso) {
134
- const now = nowIso ?? new Date().toISOString();
135
- await this.ensureDir();
136
- return this.withMutationLock(() => this.transitionRun(operation, now));
137
- }
138
- /** Persist a cooperative control intent before the next orchestration effect. */
139
- async requestLifecycleAction(action, options = {}, nowIso) {
140
- const now = nowIso ?? new Date().toISOString();
141
- await this.ensureDir();
142
- return this.withMutationLock(async () => {
143
- const state = await this.readState(now);
144
- const current = state.runControl;
145
- if (!current)
146
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
147
- const suffix = Date.parse(now).toString(36);
148
- const operationId = options.operationId ?? `op-${action}-${suffix}`;
149
- const idempotencyKey = options.idempotencyKey ?? `idem-${action}-${suffix}`;
150
- const replay = current.receipts.find((receipt) => receipt.operationId === operationId || receipt.idempotencyKey === idempotencyKey);
151
- if (replay) {
152
- if (replay.operationId === operationId && replay.idempotencyKey === idempotencyKey)
153
- return replay;
154
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
155
- }
156
- const target = localLifecycleRequestTarget(current.status, action);
157
- if (!target) {
158
- throw new Error(TERMINAL_RUN_STATUSES.has(current.status)
159
- ? 'YOLOOP_RUN_TERMINAL'
160
- : 'YOLOOP_RUN_TRANSITION_INVALID');
161
- }
162
- return this.transitionRun(buildLocalLifecycleOperation({
163
- current, action, target, operationId, idempotencyKey, occurredAt: now,
164
- }), now);
165
- });
166
- }
167
- // ── Per-operation dispatch ──────────────────────────────────────────
168
- async applyOperation(op, nowIso) {
169
- switch (op.type) {
170
- case 'acquire_lock':
171
- await this.withMutationLock(() => this.acquireLock(op.lock, nowIso));
172
- return;
173
- case 'release_lock':
174
- await this.withMutationLock(() => this.releaseLock(op));
175
- return;
176
- case 'write_state':
177
- await this.withMutationLock(async () => {
178
- const current = await this.readState(nowIso);
179
- const { runControl: _untrustedRunControl, ...legacyCounters } = op.state;
180
- await this.writeState({
181
- ...legacyCounters,
182
- ...(current.runControl
183
- ? { version: '1.2', runControl: current.runControl }
184
- : {}),
185
- });
186
- });
187
- return;
188
- case 'advance_epic':
189
- await this.advanceEpic(op.nextEpicIndex, op.nextEpicId, nowIso);
190
- return;
191
- case 'no_op':
192
- this.logger.info({ event: 'yoloop_noop' }, 'yoloop server sent no-op');
193
- return;
194
- case 'reserve_continuation_authority':
195
- await this.reserveContinuationAuthority(op.authority, nowIso);
196
- return;
197
- case 'acknowledge_continuation_authority':
198
- await this.acknowledgeContinuationAuthority(op.idempotencyKey, op.receipt, nowIso);
199
- return;
200
- case 'repair_continuation_authority':
201
- await this.repairContinuationAuthority(op.beforeHash, op.authority, op.receipt, nowIso);
202
- return;
203
- case 'switch_continuation_policy':
204
- await this.switchContinuationPolicy(op.policy, op.session, nowIso);
205
- return;
206
- case 'transition_run':
207
- await this.requestRunTransition(op, nowIso);
208
- return;
209
- case 'start_successor_run':
210
- await this.withMutationLock(() => this.startSuccessorRun(op, nowIso));
211
- return;
212
- case 'commit_successor_transition':
213
- await this.withMutationLock(() => this.commitSuccessorTransition(op, nowIso));
214
- return;
215
- case 'rollover_session':
216
- await this.withMutationLock(() => this.rolloverSession(op, nowIso));
217
- return;
218
- case 'replan_continuation_session':
219
- await this.withMutationLock(() => this.replanContinuationSession(op, nowIso));
220
- return;
221
- // ── P104.06: self-healing recovery audit ops ──────────────────────
222
- case 'recovery_attempted':
223
- await this.appendRecoveryAttempt(op.attempt, nowIso);
224
- return;
225
- case 'recovery_resolved':
226
- await this.resolveRecoveryAttempt(op.attempt_id, op.outcome, nowIso);
227
- return;
228
- default: {
229
- // Exhaustiveness check; never reached for typed inputs but
230
- // defensive against forward-compat shape drift.
231
- const _exhaustive = op;
232
- this.logger.error({ event: 'yoloop_unknown_op', op: _exhaustive }, 'unknown yoloop operation rejected');
233
- throw new Error('YOLOOP_STATE_OPERATION_UNSUPPORTED');
234
- }
235
- }
236
- }
237
- async reserveContinuationAuthority(authority, nowIso) {
238
- const current = await this.readState(nowIso);
239
- const pending = current.pendingContinuationAuthorities ?? [];
240
- const key = typeof authority.idempotencyKey === 'string' ? authority.idempotencyKey : '';
241
- if (!key)
242
- throw new Error('YOLOOP_CONTINUATION_AUTHORITY_INVALID');
243
- const existing = pending.find((item) => item.idempotencyKey === key);
244
- if (existing) {
245
- if (stablePublicJson(existing) === stablePublicJson(authority))
246
- return;
247
- throw new Error('YOLOOP_CONTINUATION_AUTHORITY_CONFLICT');
248
- }
249
- if (pending.length >= 64)
250
- throw new Error('YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED');
251
- await this.writeState({ ...current, pendingContinuationAuthorities: [...pending, authority] });
252
- }
253
- async acknowledgeContinuationAuthority(idempotencyKey, receipt, nowIso) {
254
- const current = await this.readState(nowIso);
255
- const pending = current.pendingContinuationAuthorities ?? [];
256
- const index = pending.findIndex((item) => item.idempotencyKey === idempotencyKey);
257
- if (index < 0)
258
- throw new Error('YOLOOP_CONTINUATION_RECEIPT_ORPHAN');
259
- const authority = pending[index];
260
- if (receipt.idempotencyKey !== idempotencyKey || receipt.sessionId !== authority.sessionId || JSON.stringify(receipt.storyIds) !== JSON.stringify(authority.storyIds))
261
- throw new Error('YOLOOP_CONTINUATION_RECEIPT_MISMATCH');
262
- const retained = pending.filter((_, itemIndex) => itemIndex !== index);
263
- const receipts = current.continuationReceipts ?? [];
264
- await this.writeState({ ...current, pendingContinuationAuthorities: retained, continuationReceipts: [...receipts.slice(-63), receipt] });
265
- }
266
- async repairContinuationAuthority(beforeHash, authority, receipt, nowIso) {
267
- const current = await this.readState(nowIso);
268
- const pending = current.pendingContinuationAuthorities ?? [];
269
- const recoveryReceipts = current.continuationRecoveryReceipts ?? [];
270
- const receiptKey = typeof receipt.idempotencyKey === 'string' ? receipt.idempotencyKey : '';
271
- const existingReceipt = recoveryReceipts.find((item) => item.idempotencyKey === receiptKey);
272
- if (existingReceipt) {
273
- if (stablePublicJson(existingReceipt) === stablePublicJson(receipt))
274
- return;
275
- throw new Error('YOLOOP_CONTINUATION_RECOVERY_RECEIPT_CONFLICT');
276
- }
277
- if (pending.length !== 1 || domainHash('p208-legacy-authority-before-v1', pending[0]) !== beforeHash) {
278
- throw new Error('YOLOOP_CONTINUATION_RECOVERY_BEFORE_MISMATCH');
279
- }
280
- const afterHash = domainHash('p208-legacy-authority-after-v1', authority);
281
- if (receipt.beforeHash !== beforeHash || receipt.afterHash !== afterHash) {
282
- throw new Error('YOLOOP_CONTINUATION_RECOVERY_HASH_MISMATCH');
283
- }
284
- const receiptBase = { ...receipt };
285
- delete receiptBase.idempotencyKey;
286
- if (!receiptKey || domainHash('p208-legacy-authority-recovery-receipt-v1', receiptBase) !== receiptKey) {
287
- throw new Error('YOLOOP_CONTINUATION_RECOVERY_RECEIPT_INVALID');
288
- }
289
- await this.writeState({
290
- ...current,
291
- pendingContinuationAuthorities: [authority],
292
- continuationRecoveryReceipts: [...recoveryReceipts.slice(-63), receipt],
293
- });
294
- }
295
- async switchContinuationPolicy(policy, session, nowIso) {
296
- const current = await this.readState(nowIso);
297
- if (current.currentSession || (current.pendingContinuationAuthorities?.length ?? 0) > 0)
298
- throw new Error('YOLOOP_POLICY_SWITCH_ACTIVE');
299
- try {
300
- await fs.copyFile(this.statePath, `${this.statePath}.policy-backup`);
301
- }
302
- catch (error) {
303
- if (error.code !== 'ENOENT')
304
- throw error;
305
- }
306
- await this.writeState({ ...current, continuationPolicy: policy, continuationSession: session });
307
- }
308
- async replanContinuationSession(operation, nowIso) {
309
- const { type: _type, payloadHash: _payloadHash, receipt: _receipt, ...payload } = operation;
310
- if (buildYoloopContinuationReplanPayloadHash(payload) !== operation.payloadHash
311
- || !isSealedYoloopFrozenSession(operation.nextSession)) {
312
- throw new Error('YOLOOP_CONTINUATION_REPLAN_PAYLOAD_INVALID');
313
- }
314
- const expectedReceipt = {
315
- schemaVersion: 'YoloopContinuationReplanReceipt/1.0',
316
- operationId: operation.operationId,
317
- idempotencyKey: operation.idempotencyKey,
318
- runId: operation.expectedRunId,
319
- previousSessionHash: operation.expectedSessionHash,
320
- nextSessionHash: domainHash('p222-yoloop-replan-session-v1', operation.nextSession),
321
- retiredAuthorityHash: operation.expectedPendingHash,
322
- retiredAuthorityCount: operation.expectedPendingHash === domainHash('p222-yoloop-replan-pending-v1', []) ? 0 : 1,
323
- payloadHash: operation.payloadHash,
324
- outcome: 'graph-replanned',
325
- publicSafe: true,
326
- };
327
- if (stablePublicJson(operation.receipt) !== stablePublicJson(expectedReceipt)) {
328
- throw new Error('YOLOOP_CONTINUATION_REPLAN_RECEIPT_INVALID');
329
- }
330
- const current = await this.readState(nowIso);
331
- if (await this.readLock() || current.currentSession !== null) {
332
- throw new Error('YOLOOP_CONTINUATION_REPLAN_ACTIVE');
333
- }
334
- const run = current.runControl;
335
- if (!run || run.runId !== operation.expectedRunId
336
- || !['success', 'exhausted'].includes(run.status)
337
- || run.revision !== operation.expectedRevision
338
- || run.latestSnapshotHash !== operation.expectedSnapshotHash
339
- || current.continuationPolicy !== 'p205-finite-resume-v2') {
340
- throw new Error('YOLOOP_CONTINUATION_REPLAN_RUN_CONFLICT');
341
- }
342
- const receipts = current.continuationReplanReceipts ?? [];
343
- const existing = receipts.find((item) => item.idempotencyKey === operation.idempotencyKey
344
- || item.operationId === operation.operationId);
345
- if (existing) {
346
- if (stablePublicJson(existing) === stablePublicJson(expectedReceipt)
347
- && stablePublicJson(current.continuationSession) === stablePublicJson(operation.nextSession)
348
- && (current.pendingContinuationAuthorities?.length ?? 0) === 0)
349
- return;
350
- throw new Error('YOLOOP_CONTINUATION_REPLAN_REPLAY_CONFLICT');
351
- }
352
- const session = current.continuationSession;
353
- const pending = current.pendingContinuationAuthorities ?? [];
354
- if (!isSealedYoloopFrozenSession(session)
355
- || domainHash('p222-yoloop-replan-session-v1', session) !== operation.expectedSessionHash
356
- || domainHash('p222-yoloop-replan-pending-v1', pending) !== operation.expectedPendingHash
357
- || pending.length > 1
358
- || pending.some((authority) => authority.delegated !== false
359
- || authority.sessionId !== session.sessionId
360
- || !authority.certificate
361
- || authority.certificate.sessionId !== session.sessionId)) {
362
- throw new Error('YOLOOP_CONTINUATION_REPLAN_AUTHORITY_CONFLICT');
363
- }
364
- await this.writeState({
365
- ...current,
366
- continuationSession: operation.nextSession,
367
- pendingContinuationAuthorities: [],
368
- continuationReplanReceipts: [...receipts.slice(-63), expectedReceipt],
369
- });
370
- }
371
- // ── P104.06: recovery_history audit semantics ───────────────────────────
372
- /**
373
- * Append a new recovery attempt to `state.recovery_history[]`. FIFO cap
374
- * via `capRecoveryHistory`. Idempotent against retry storms: duplicate
375
- * (story_id + attempted_at) entries are skipped with a warn log.
376
- */
377
- async appendRecoveryAttempt(attempt, nowIso) {
378
- const current = await this.readState(nowIso);
379
- const history = current.recovery_history ?? [];
380
- // P104.06 AC8: idempotency via composite key
381
- const isDuplicate = history.some((a) => a.attempted_at === attempt.attempted_at && a.story_id === attempt.story_id);
382
- if (isDuplicate) {
383
- this.logger.warn({ event: 'recovery_attempted_duplicate', story_id: attempt.story_id }, 'duplicate recovery_attempted op; skipping');
384
- return;
385
- }
386
- const next = capRecoveryHistory([...history, attempt]);
387
- const updated = {
388
- ...current,
389
- recovery_history: next,
390
- };
391
- await this.writeState(updated);
392
- this.logger.info({
393
- event: 'recovery_attempted_persisted',
394
- story_id: attempt.story_id,
395
- history_size: next.length,
396
- }, 'recovery attempt persisted');
397
- }
398
- /**
399
- * Resolve a pending recovery attempt by tokenized step id. Updates
400
- * `outcome` and stamps `finished_at`. If the attempt was already evicted
401
- * by the FIFO cap or never existed, emits a warn log (`recovery_resolve_orphan`)
402
- * but does not throw -- bound state cap is a deliberate trade-off.
403
- */
404
- async resolveRecoveryAttempt(attemptId, outcome, nowIso) {
405
- const current = await this.readState(nowIso);
406
- const history = current.recovery_history ?? [];
407
- const idx = history.findIndex((a) => a.recovery_step_id_tokenized === attemptId);
408
- if (idx === -1) {
409
- this.logger.warn({ event: 'recovery_resolve_orphan', attempt_id: attemptId }, 'recovery_resolved op had no matching attempt; skipping');
410
- return;
411
- }
412
- const currentAttempt = history[idx];
413
- if (currentAttempt.outcome === outcome && currentAttempt.finished_at) {
414
- this.logger.info({ event: 'recovery_resolved_duplicate', attempt_id: attemptId, outcome }, 'duplicate recovery_resolved op; skipping');
415
- return;
416
- }
417
- const updatedHistory = history.slice();
418
- updatedHistory[idx] = {
419
- ...currentAttempt,
420
- outcome,
421
- finished_at: nowIso,
422
- };
423
- const updated = {
424
- ...current,
425
- recovery_history: updatedHistory,
426
- };
427
- await this.writeState(updated);
428
- this.logger.info({ event: 'recovery_resolved_persisted', attempt_id: attemptId, outcome }, 'recovery attempt resolved');
429
- }
430
- // ── Lock semantics (AC5 + AC6) ──────────────────────────────────────
431
- /**
432
- * Atomically acquire the yoloop lock. On EEXIST, performs PID liveness
433
- * check (same-host) and retries once if orphan detected. Cross-host
434
- * locks are honored (cannot signal across hosts).
435
- *
436
- * The server's `acquire_lock` operation provides the lock payload; we
437
- * overwrite the `pid` and `hostname` fields with OUR values (the client
438
- * is the authoritative writer of pid/hostname; server can't know them).
439
- */
440
- async acquireLock(lock, nowIso) {
441
- // Replace server-suggested pid+hostname with real client values.
442
- const payload = {
443
- ...lock,
444
- pid: process.pid,
445
- hostname: hostname(),
446
- ownerKind: 'client',
447
- startedAt: lock.startedAt || nowIso,
448
- leaseId: lock.leaseId ?? buildPublicLeaseId(lock.sessionId, nowIso),
449
- leaseState: 'active',
450
- leaseAcquiredAt: lock.leaseAcquiredAt ?? nowIso,
451
- leaseDurationMs: lock.leaseDurationMs ?? DEFAULT_LOCK_LEASE_MS,
452
- leaseExpiresAt: lock.leaseExpiresAt ?? lock.ttlExpiresAt,
453
- };
454
- const json = JSON.stringify(payload, null, 2);
455
- try {
456
- const fh = await fs.open(this.lockPath, 'wx');
457
- try {
458
- await fh.writeFile(json, 'utf8');
459
- }
460
- finally {
461
- await fh.close();
462
- }
463
- this.trySetFilePerms(this.lockPath);
464
- this.logger.info({ event: 'yoloop_lock_acquired', pid: payload.pid, sessionId: payload.sessionId }, 'yoloop lock acquired');
465
- return;
466
- }
467
- catch (err) {
468
- const e = err;
469
- if (e.code !== 'EEXIST') {
470
- this.logger.error({ event: 'yoloop_lock_acquire_error', err: e.message }, 'failed to acquire yoloop lock');
471
- throw err;
472
- }
473
- const existing = await this.readLock();
474
- if (existing && this.isSameClientSessionLock(existing, payload)) {
475
- this.logger.info({ event: 'yoloop_lock_acquire_duplicate', sessionId: payload.sessionId }, 'duplicate acquire_lock for current yoloop session; treating as no-op');
476
- return;
477
- }
478
- // EEXIST: try orphan recovery once.
479
- const recovered = await this.maybeRecoverOrphanLock(nowIso);
480
- if (!recovered) {
481
- this.logger.warn({ event: 'yoloop_lock_held' }, 'yoloop lock held by another live process');
482
- throw err;
483
- }
484
- // Retry after orphan recovery.
485
- const fh = await fs.open(this.lockPath, 'wx');
486
- try {
487
- await fh.writeFile(json, 'utf8');
488
- }
489
- finally {
490
- await fh.close();
491
- }
492
- this.trySetFilePerms(this.lockPath);
493
- this.logger.info({ event: 'yoloop_lock_acquired_after_recovery', pid: payload.pid }, 'yoloop lock acquired after orphan recovery');
494
- }
495
- }
496
- /** Release the lock. V1.2 active runs require compare-bound ownership. */
497
- async releaseLock(binding) {
498
- const current = await this.readState(new Date().toISOString());
499
- const lock = await this.readLock();
500
- if (current.runControl) {
501
- if (!binding.expectedRunId || !binding.expectedSessionId || !binding.expectedLeaseId) {
502
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
503
- }
504
- if (binding.expectedRunId !== current.runControl.runId) {
505
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
506
- }
507
- if (!lock
508
- || binding.expectedSessionId !== lock.sessionId
509
- || binding.expectedLeaseId !== lock.leaseId) {
510
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
511
- }
512
- }
513
- try {
514
- await fs.unlink(this.lockPath);
515
- this.logger.info({ event: 'yoloop_lock_released' }, 'yoloop lock released');
516
- }
517
- catch (err) {
518
- const e = err;
519
- if (e.code === 'ENOENT') {
520
- this.logger.info({ event: 'yoloop_lock_release_noop' }, 'yoloop lock release noop (file absent)');
521
- return;
522
- }
523
- this.logger.warn({ event: 'yoloop_lock_release_error', err: e.message }, 'unexpected error releasing yoloop lock');
524
- }
525
- }
526
- /** Read lock file. Returns null on ENOENT / parse error. */
527
- async readLock() {
528
- try {
529
- const raw = await fs.readFile(this.lockPath, 'utf8');
530
- const parsed = JSON.parse(raw);
531
- const currentEpicId = parsed.currentEpicId ?? parsed.epicId ?? '';
532
- return {
533
- pid: parsed.pid ?? 0,
534
- hostname: parsed.hostname ?? '',
535
- ...(parsed.ownerKind ? { ownerKind: parsed.ownerKind } : {}),
536
- ...(parsed.ownerPidState ? { ownerPidState: parsed.ownerPidState } : {}),
537
- ...(typeof parsed.leaseId === 'string' ? { leaseId: parsed.leaseId } : {}),
538
- ...(parsed.leaseState ? { leaseState: parsed.leaseState } : {}),
539
- ...(typeof parsed.leaseAcquiredAt === 'string' ? { leaseAcquiredAt: parsed.leaseAcquiredAt } : {}),
540
- ...(typeof parsed.leaseDurationMs === 'number' ? { leaseDurationMs: parsed.leaseDurationMs } : {}),
541
- ...(typeof parsed.leaseExpiresAt === 'string' ? { leaseExpiresAt: parsed.leaseExpiresAt } : {}),
542
- startedAt: parsed.startedAt ?? '',
543
- sessionId: parsed.sessionId ?? '',
544
- mode: parsed.mode ?? 'epic-id',
545
- inputPath: parsed.inputPath ?? '',
546
- epicQueue: parsed.epicQueue ?? (currentEpicId ? [currentEpicId] : []),
547
- currentEpicIndex: parsed.currentEpicIndex ?? 0,
548
- currentEpicId,
549
- ttlExpiresAt: parsed.ttlExpiresAt ?? '',
550
- epicId: parsed.epicId ?? currentEpicId,
551
- };
552
- }
553
- catch {
554
- return null;
555
- }
556
- }
557
- // ── State semantics (AC5 + AC7) ─────────────────────────────────────
558
- /** Read state file; returns defaults on any I/O or parse error. */
559
- async readState(nowIso) {
560
- try {
561
- const raw = await fs.readFile(this.statePath, 'utf8');
562
- const parsed = JSON.parse(raw);
563
- return mergeCountersWithDefaults(parsed, nowIso);
564
- }
565
- catch (err) {
566
- const e = err;
567
- if (e.code !== 'ENOENT') {
568
- this.logger.warn({ event: 'yoloop_state_read_error', err: e.message }, 'yoloop state file unreadable; using defaults');
569
- }
570
- return createDefaultCounters(nowIso);
571
- }
572
- }
573
- /** Durable atomic state write: same-filesystem temp + fsync + rename + dir fsync. */
574
- async writeState(state) {
575
- const tmpPath = `${this.statePath}.tmp.${process.pid}.${Date.now()}`;
576
- const json = JSON.stringify(state, null, 2);
577
- try {
578
- const handle = await fs.open(tmpPath, 'w', 0o600);
579
- try {
580
- await handle.writeFile(json, 'utf8');
581
- await handle.sync();
582
- }
583
- finally {
584
- await handle.close();
585
- }
586
- this.trySetFilePerms(tmpPath);
587
- await fs.rename(tmpPath, this.statePath);
588
- await this.syncStateDirectory();
589
- }
590
- catch (err) {
591
- const e = err;
592
- this.logger.error({ event: 'yoloop_state_write_error', err: e.message }, 'failed to write yoloop state');
593
- try {
594
- await fs.unlink(tmpPath);
595
- }
596
- catch {
597
- // best-effort cleanup
598
- }
599
- throw err;
600
- }
601
- }
602
- /**
603
- * Apply the per-run-cap boundary as one journaled logical transaction over
604
- * yoloop-state.json and yoloop.lock. The mutation lock serializes local
605
- * writers; the durable manifest makes every crash point resumable.
606
- */
607
- async rolloverSession(operation, nowIso) {
608
- validateBoundedSessionRollover(operation);
609
- const manifest = await this.readRolloverManifest();
610
- if (manifest) {
611
- if (!sameRolloverIdentity(manifest.operation, operation)) {
612
- if (manifest.phase === 'prepared')
613
- throw new Error('YOLOOP_SESSION_ROLLOVER_CONFLICT');
614
- }
615
- else {
616
- if (manifest.operation.payloadHash !== operation.payloadHash) {
617
- throw new Error('YOLOOP_SESSION_ROLLOVER_CONFLICT');
618
- }
619
- if (manifest.phase === 'applied')
620
- return;
621
- await this.completePreparedSessionRollover(manifest.operation, nowIso);
622
- return;
623
- }
624
- }
625
- const current = await this.readState(nowIso);
626
- const lock = await this.readLock();
627
- validateSessionRolloverBeforeState(operation, current, lock);
628
- await this.writeRolloverManifest({
629
- schemaVersion: 'YoloopSessionRolloverManifest/1.0',
630
- phase: 'prepared',
631
- operation,
632
- });
633
- await this.completePreparedSessionRollover(operation, nowIso);
634
- }
635
- async completePreparedSessionRollover(operation, nowIso) {
636
- const current = await this.readState(nowIso);
637
- let stateApplied = stablePublicJson(current) === stablePublicJson(operation.next);
638
- if (!stateApplied) {
639
- const lockBefore = await this.readLock();
640
- validateSessionRolloverBeforeState(operation, current, lockBefore);
641
- await this.writeState(operation.next);
642
- stateApplied = true;
643
- }
644
- const lock = await this.readLock();
645
- if (operation.expectedSessionId === null || operation.expectedLeaseId === null) {
646
- if (operation.expectedSessionId !== null || operation.expectedLeaseId !== null || lock) {
647
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
648
- }
649
- }
650
- else if (lock) {
651
- if (lock.sessionId !== operation.expectedSessionId
652
- || lock.leaseId !== operation.expectedLeaseId) {
653
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
654
- }
655
- await fs.unlink(this.lockPath);
656
- await this.syncStateDirectory();
657
- }
658
- else if (!stateApplied) {
659
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
660
- }
661
- await this.writeRolloverManifest({
662
- schemaVersion: 'YoloopSessionRolloverManifest/1.0',
663
- phase: 'applied',
664
- operation,
665
- });
666
- this.logger.info({
667
- event: 'yoloop_session_rollover_applied',
668
- runId: operation.runId,
669
- operationId: operation.operationId,
670
- coolDownUntil: operation.coolDownUntil,
671
- }, 'yoloop per-run-cap session rollover applied');
672
- }
673
- async recoverSessionRolloverIfPresent(nowIso) {
674
- try {
675
- await fs.access(this.rolloverManifestPath);
676
- }
677
- catch (error) {
678
- if (error.code === 'ENOENT')
679
- return;
680
- throw error;
681
- }
682
- await this.ensureDir();
683
- await this.withMutationLock(async () => {
684
- const manifest = await this.readRolloverManifest();
685
- if (manifest?.phase === 'prepared') {
686
- await this.completePreparedSessionRollover(manifest.operation, nowIso);
687
- }
688
- });
689
- }
690
- async readRolloverManifest() {
691
- try {
692
- const parsed = JSON.parse(await fs.readFile(this.rolloverManifestPath, 'utf8'));
693
- if (parsed.schemaVersion !== 'YoloopSessionRolloverManifest/1.0'
694
- || (parsed.phase !== 'prepared' && parsed.phase !== 'applied')
695
- || !parsed.operation
696
- || parsed.operation.type !== 'rollover_session')
697
- throw new Error('YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT');
698
- validateBoundedSessionRollover(parsed.operation);
699
- return parsed;
700
- }
701
- catch (error) {
702
- if (error.code === 'ENOENT')
703
- return null;
704
- if (error.message === 'YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT')
705
- throw error;
706
- throw new Error('YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT');
707
- }
708
- }
709
- async writeRolloverManifest(manifest) {
710
- const tmpPath = `${this.rolloverManifestPath}.tmp.${process.pid}.${Date.now()}`;
711
- const handle = await fs.open(tmpPath, 'w', 0o600);
712
- try {
713
- await handle.writeFile(JSON.stringify(manifest, null, 2), 'utf8');
714
- await handle.sync();
715
- }
716
- finally {
717
- await handle.close();
718
- }
719
- this.trySetFilePerms(tmpPath);
720
- await fs.rename(tmpPath, this.rolloverManifestPath);
721
- await this.syncStateDirectory();
722
- }
723
- /**
724
- * Journal the predecessor replacement, completed-story acknowledgement and
725
- * next authority reservation as one recoverable logical CAS transition.
726
- * Individual state-file renames remain atomic; the prepared/applied manifest
727
- * closes every crash window between them.
728
- */
729
- async commitSuccessorTransition(operation, nowIso) {
730
- validateBoundedSuccessorTransition(operation);
731
- const manifest = await this.readSuccessorTransitionManifest();
732
- if (manifest) {
733
- if (sameSuccessorTransitionIdentity(manifest.operation, operation)) {
734
- if (manifest.operation.payloadHash !== operation.payloadHash) {
735
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_CONFLICT');
736
- }
737
- if (manifest.phase === 'applied')
738
- return;
739
- await this.completePreparedSuccessorTransition(manifest.operation, nowIso);
740
- return;
741
- }
742
- if (manifest.phase === 'prepared') {
743
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_CONFLICT');
744
- }
745
- }
746
- const current = await this.readState(nowIso);
747
- if (successorTransitionApplied(operation, current)) {
748
- await this.writeSuccessorTransitionManifest({
749
- schemaVersion: 'YoloopSuccessorTransitionManifest/1.0',
750
- phase: 'applied',
751
- operation,
752
- });
753
- return;
754
- }
755
- validateSuccessorTransitionBeforeState(operation, current);
756
- await this.writeSuccessorTransitionManifest({
757
- schemaVersion: 'YoloopSuccessorTransitionManifest/1.0',
758
- phase: 'prepared',
759
- operation,
760
- });
761
- await this.completePreparedSuccessorTransition(operation, nowIso);
762
- }
763
- async completePreparedSuccessorTransition(operation, nowIso) {
764
- let current = await this.readState(nowIso);
765
- if (!successorTransitionApplied(operation, current)) {
766
- const successorApplied = stablePublicJson(current.runControl)
767
- === stablePublicJson(operation.successor.next);
768
- if (!successorApplied) {
769
- await this.startSuccessorRun(operation.successor, nowIso);
770
- current = await this.readState(nowIso);
771
- }
772
- const next = buildSuccessorTransitionState(operation, current);
773
- await this.writeState(next);
774
- current = await this.readState(nowIso);
775
- }
776
- if (!successorTransitionApplied(operation, current)) {
777
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT');
778
- }
779
- await this.writeSuccessorTransitionManifest({
780
- schemaVersion: 'YoloopSuccessorTransitionManifest/1.0',
781
- phase: 'applied',
782
- operation,
783
- });
784
- this.logger.info({
785
- event: 'yoloop_successor_transition_applied',
786
- runId: operation.successor.runId,
787
- operationId: operation.operationId,
788
- }, 'yoloop successor transition applied');
789
- }
790
- async recoverSuccessorTransitionIfPresent(nowIso) {
791
- try {
792
- await fs.access(this.successorTransitionManifestPath);
793
- }
794
- catch (error) {
795
- if (error.code === 'ENOENT')
796
- return;
797
- throw error;
798
- }
799
- await this.ensureDir();
800
- await this.withMutationLock(async () => {
801
- const manifest = await this.readSuccessorTransitionManifest();
802
- if (manifest?.phase === 'prepared') {
803
- await this.completePreparedSuccessorTransition(manifest.operation, nowIso);
804
- }
805
- });
806
- }
807
- async readSuccessorTransitionManifest() {
808
- try {
809
- const parsed = JSON.parse(await fs.readFile(this.successorTransitionManifestPath, 'utf8'));
810
- if (parsed.schemaVersion !== 'YoloopSuccessorTransitionManifest/1.0'
811
- || (parsed.phase !== 'prepared' && parsed.phase !== 'applied')
812
- || !parsed.operation
813
- || parsed.operation.type !== 'commit_successor_transition')
814
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT');
815
- validateBoundedSuccessorTransition(parsed.operation);
816
- return parsed;
817
- }
818
- catch (error) {
819
- if (error.code === 'ENOENT')
820
- return null;
821
- if (error.message === 'YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT')
822
- throw error;
823
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT');
824
- }
825
- }
826
- async writeSuccessorTransitionManifest(manifest) {
827
- const tmpPath = `${this.successorTransitionManifestPath}.tmp.${process.pid}.${Date.now()}`;
828
- const handle = await fs.open(tmpPath, 'w', 0o600);
829
- try {
830
- await handle.writeFile(JSON.stringify(manifest, null, 2), 'utf8');
831
- await handle.sync();
832
- }
833
- finally {
834
- await handle.close();
835
- }
836
- this.trySetFilePerms(tmpPath);
837
- await fs.rename(tmpPath, this.successorTransitionManifestPath);
838
- await this.syncStateDirectory();
839
- }
840
- /**
841
- * Advance to next epic: read-modify-write on currentSession.currentEpicIndex.
842
- * Fail-open if no active session (logs warn; subsequent *yoloop will
843
- * emit a fresh acquire_lock).
844
- */
845
- async advanceEpic(nextIndex, nextEpicId, nowIso) {
846
- const current = await this.readState(nowIso);
847
- if (!current.currentSession) {
848
- this.logger.warn({ event: 'yoloop_advance_no_session', nextEpicId }, 'advance_epic with no active session; skipping');
849
- return;
850
- }
851
- const updated = {
852
- ...current,
853
- currentSession: {
854
- ...current.currentSession,
855
- currentEpicIndex: nextIndex,
856
- },
857
- };
858
- await this.writeState(updated);
859
- this.logger.info({ event: 'yoloop_epic_advance', nextEpicId, nextIndex }, 'yoloop advanced to next epic');
860
- }
861
- // ── Internal helpers ────────────────────────────────────────────────
862
- async ensureDir() {
863
- try {
864
- await fs.mkdir(this.stateDir, { recursive: true });
865
- this.trySetDirPerms(this.stateDir);
866
- }
867
- catch (err) {
868
- const e = err;
869
- this.logger.error({ event: 'yoloop_mkdir_error', err: e.message, path: this.stateDir }, 'failed to ensure .neocortex/ directory');
870
- throw err;
871
- }
872
- }
873
- async syncStateDirectory() {
874
- try {
875
- const directory = await fs.open(this.stateDir, 'r');
876
- try {
877
- await directory.sync();
878
- }
879
- finally {
880
- await directory.close();
881
- }
882
- }
883
- catch (error) {
884
- const code = error.code;
885
- if (code !== 'EINVAL' && code !== 'EISDIR' && code !== 'ENOTSUP' && code !== 'EPERM') {
886
- throw error;
887
- }
888
- }
889
- }
890
- async withMutationLock(operation) {
891
- let handle = null;
892
- for (let attempt = 0; attempt < MUTATION_LOCK_RETRIES; attempt += 1) {
893
- try {
894
- handle = await fs.open(this.mutationLockPath, 'wx', 0o600);
895
- await handle.writeFile(JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }));
896
- await handle.sync();
897
- break;
898
- }
899
- catch (error) {
900
- if (error.code !== 'EEXIST')
901
- throw error;
902
- try {
903
- const stat = await fs.stat(this.mutationLockPath);
904
- if (Date.now() - stat.mtimeMs > MUTATION_LOCK_STALE_MS) {
905
- await fs.unlink(this.mutationLockPath);
906
- continue;
907
- }
908
- }
909
- catch {
910
- continue;
911
- }
912
- await new Promise((resolve) => setTimeout(resolve, 5));
913
- }
914
- }
915
- if (!handle)
916
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
917
- try {
918
- return await operation();
919
- }
920
- finally {
921
- await handle.close().catch(() => undefined);
922
- await fs.unlink(this.mutationLockPath).catch(() => undefined);
923
- }
924
- }
925
- async transitionRun(operation, nowIso) {
926
- validateBoundedRunOperation(operation);
927
- const current = await this.readState(nowIso);
928
- const existing = current.runControl;
929
- const replay = existing?.receipts.find((receipt) => receipt.operationId === operation.operationId
930
- || receipt.idempotencyKey === operation.idempotencyKey);
931
- if (replay) {
932
- if (replay.operationId === operation.operationId
933
- && replay.idempotencyKey === operation.idempotencyKey
934
- && replay.payloadHash === operation.payloadHash)
935
- return replay;
936
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
937
- }
938
- if (existing && existing.runId !== operation.runId) {
939
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
940
- }
941
- const actualRevision = existing?.revision ?? 0;
942
- if (actualRevision !== operation.expectedRevision) {
943
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
944
- }
945
- const actualSnapshotHash = existing?.latestSnapshotHash ?? null;
946
- if (actualSnapshotHash !== operation.expectedSnapshotHash) {
947
- throw new Error('YOLOOP_RUN_SNAPSHOT_CONFLICT');
948
- }
949
- validateProposedRunTransition(existing, operation);
950
- const receipt = operation.next.receipts.at(-1);
951
- if (!receipt)
952
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
953
- await this.writeState({ ...current, version: '1.2', runControl: operation.next });
954
- this.logger.info({
955
- event: 'yoloop_run_transition_committed',
956
- runId: operation.runId,
957
- operationId: operation.operationId,
958
- revision: receipt.revision,
959
- status: receipt.status,
960
- }, 'yoloop run transition committed');
961
- return receipt;
962
- }
963
- /** Replace a proven exhausted run, or an explicitly reopened success, with a fresh successor. */
964
- async startSuccessorRun(operation, nowIso) {
965
- validateBoundedSuccessorRunOperation(operation);
966
- const current = await this.readState(nowIso);
967
- const existing = current.runControl;
968
- const expectedPredecessor = {
969
- schemaVersion: 'YoloopRunPredecessor/1.0',
970
- runId: operation.previousRunId,
971
- revision: operation.expectedRevision,
972
- snapshotHash: operation.expectedSnapshotHash,
973
- successorOperationId: operation.operationId,
974
- publicSafe: true,
975
- };
976
- const replay = existing?.receipts.find((receipt) => receipt.operationId === operation.operationId
977
- || receipt.idempotencyKey === operation.idempotencyKey);
978
- if (replay) {
979
- if (replay.operationId === operation.operationId
980
- && replay.idempotencyKey === operation.idempotencyKey
981
- && operation.next.receipts.at(-1)?.payloadHash === replay.payloadHash
982
- && stablePublicJson(existing?.predecessor) === stablePublicJson(expectedPredecessor))
983
- return replay;
984
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
985
- }
986
- if (!existing
987
- || existing.runId !== operation.previousRunId
988
- || !['exhausted', 'success'].includes(existing.status)
989
- || existing.revision !== operation.expectedRevision
990
- || existing.latestSnapshotHash !== operation.expectedSnapshotHash
991
- || current.currentSession !== null)
992
- throw new Error('YOLOOP_RUN_SUCCESSOR_CONFLICT');
993
- const createdReceipt = operation.next.receipts.at(-1);
994
- if (!createdReceipt
995
- || stablePublicJson(operation.next.predecessor) !== stablePublicJson(expectedPredecessor))
996
- throw new Error('YOLOOP_RUN_SUCCESSOR_CONFLICT');
997
- const createdTransition = {
998
- type: 'transition_run',
999
- runId: operation.runId,
1000
- operationId: operation.operationId,
1001
- idempotencyKey: operation.idempotencyKey,
1002
- expectedRevision: 0,
1003
- expectedSnapshotHash: null,
1004
- payloadHash: createdReceipt.payloadHash,
1005
- next: operation.next,
1006
- };
1007
- validateBoundedRunOperation(createdTransition);
1008
- validateProposedRunTransition(undefined, createdTransition);
1009
- await this.writeState({ ...current, version: '1.2', runControl: operation.next });
1010
- return createdReceipt;
1011
- }
1012
- /**
1013
- * Fire-and-forget chmod/ACL. Uses sync helpers from secure-config.ts
1014
- * which are fail-open by contract (never throw).
1015
- */
1016
- trySetFilePerms(path) {
1017
- try {
1018
- setSecureFilePermissions(path);
1019
- }
1020
- catch {
1021
- // setSecureFilePermissions is fail-open but be defensive
1022
- }
1023
- }
1024
- trySetDirPerms(path) {
1025
- try {
1026
- setSecureDirPermissions(path);
1027
- }
1028
- catch {
1029
- // ignore
1030
- }
1031
- }
1032
- /**
1033
- * Normalize a lock before echoing it to the server. The local client can
1034
- * check PID liveness for client-owned same-host locks; the server cannot.
1035
- */
1036
- async prepareLockForSnapshot(meta, nowIso) {
1037
- if (!meta)
1038
- return null;
1039
- if (meta.ownerKind !== 'client') {
1040
- return meta;
1041
- }
1042
- const leaseState = classifyClientLease(meta, nowIso);
1043
- const metaWithLease = { ...meta, leaseState };
1044
- if (leaseState === 'expired') {
1045
- await this.deleteLockBestEffort();
1046
- this.logger.warn({ event: 'yoloop_client_lock_recovered', reason: 'lease_expired' }, 'expired client-owned yoloop lock lease recovered before snapshot echo');
1047
- return null;
1048
- }
1049
- if (!metaWithLease.pid || metaWithLease.pid <= 0) {
1050
- await this.deleteLockBestEffort();
1051
- this.logger.warn({ event: 'yoloop_client_lock_recovered', reason: 'invalid_pid' }, 'invalid PID in client-owned yoloop lock; recovered');
1052
- return null;
1053
- }
1054
- if (metaWithLease.hostname && metaWithLease.hostname !== hostname()) {
1055
- return { ...metaWithLease, ownerPidState: 'unknown' };
1056
- }
1057
- try {
1058
- process.kill(metaWithLease.pid, 0);
1059
- return { ...metaWithLease, ownerPidState: 'live' };
1060
- }
1061
- catch {
1062
- await this.deleteLockBestEffort();
1063
- this.logger.warn({ event: 'yoloop_client_lock_recovered', reason: 'dead_pid' }, 'stale client-owned yoloop lock recovered before snapshot echo');
1064
- return null;
1065
- }
1066
- }
1067
- /**
1068
- * If the existing lock is held by a dead PID on the same host, delete
1069
- * it and return true (caller retries acquisition). Otherwise return
1070
- * false (honor the lock).
1071
- */
1072
- async maybeRecoverOrphanLock(nowIso) {
1073
- const meta = await this.readLock();
1074
- if (!meta) {
1075
- // Lock file disappeared between EEXIST and our read -> safe to retry
1076
- return true;
1077
- }
1078
- if (classifyClientLease(meta, nowIso) === 'expired') {
1079
- await this.deleteLockBestEffort();
1080
- this.logger.warn({ event: 'yoloop_orphan_lock_recovered', reason: 'lease_expired' }, 'expired yoloop lock lease recovered');
1081
- return true;
1082
- }
1083
- if (!meta.pid || meta.pid <= 0) {
1084
- await this.deleteLockBestEffort();
1085
- this.logger.warn({ event: 'yoloop_orphan_lock_recovered', reason: 'invalid_pid' }, 'invalid PID in lock file; recovered');
1086
- return true;
1087
- }
1088
- // Cross-host locks cannot be killed; honor them.
1089
- if (meta.hostname && meta.hostname !== hostname()) {
1090
- this.logger.warn({
1091
- event: 'yoloop_lock_remote_host',
1092
- ownerKind: meta.ownerKind ?? 'server',
1093
- ownerPidState: 'unknown',
1094
- }, 'lock held by a remote host; honoring');
1095
- return false;
1096
- }
1097
- try {
1098
- process.kill(meta.pid, 0); // liveness check
1099
- return false; // alive -> honor
1100
- }
1101
- catch {
1102
- await this.deleteLockBestEffort();
1103
- this.logger.warn({ event: 'yoloop_orphan_lock_recovered', reason: 'dead_pid' }, 'orphan yoloop lock recovered');
1104
- return true;
1105
- }
1106
- }
1107
- async deleteLockBestEffort() {
1108
- try {
1109
- await fs.unlink(this.lockPath);
1110
- }
1111
- catch {
1112
- // ignore
1113
- }
1114
- }
1115
- isSameClientSessionLock(existing, next) {
1116
- return (existing.ownerKind === 'client' &&
1117
- existing.sessionId === next.sessionId &&
1118
- existing.pid === process.pid &&
1119
- existing.hostname === hostname());
1120
- }
1121
- /** Remove `yoloop-state.json.tmp.*` orphans from crashed writes. */
1122
- async cleanupOrphanTempFiles() {
1123
- try {
1124
- const dir = dirname(this.statePath);
1125
- const entries = await fs.readdir(dir);
1126
- const stems = [
1127
- `${STATE_FILE}.tmp.`,
1128
- `${ROLLOVER_MANIFEST_FILE}.tmp.`,
1129
- `${SUCCESSOR_TRANSITION_MANIFEST_FILE}.tmp.`,
1130
- ];
1131
- const candidates = entries.filter((entry) => stems.some((stem) => entry.startsWith(stem)));
1132
- for (const entry of candidates) {
1133
- try {
1134
- await fs.unlink(join(dir, entry));
1135
- }
1136
- catch {
1137
- // ignore individual failures
1138
- }
1139
- }
1140
- }
1141
- catch {
1142
- // dir may not exist yet; ignore
1143
- }
1144
- }
1145
- }
1146
- function stablePublicJson(value) {
1147
- if (value === null || typeof value !== 'object')
1148
- return JSON.stringify(value);
1149
- if (Array.isArray(value))
1150
- return `[${value.map(stablePublicJson).join(',')}]`;
1151
- return `{${Object.entries(value).filter(([, child]) => child !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => `${JSON.stringify(key)}:${stablePublicJson(child)}`).join(',')}}`;
1152
- }
1153
- function domainHash(domain, value) {
1154
- return createHash('sha256').update(`${domain}\0${stablePublicJson(value)}`).digest('hex');
1155
- }
1156
- export function buildYoloopContinuationReplanPayloadHash(payload) {
1157
- return domainHash('p222-yoloop-continuation-replan-v1', payload);
1158
- }
1159
- function isSealedYoloopFrozenSession(value) {
1160
- if (!value || typeof value !== 'object' || Array.isArray(value))
1161
- return false;
1162
- const { sessionId, ...pinned } = value;
1163
- return typeof sessionId === 'string'
1164
- && /^[a-f0-9]{64}$/.test(sessionId)
1165
- && sessionId === domainHash('p205-yoloop-session-v1', pinned);
1166
- }
1167
- export function fingerprintYoloopSession(session) {
1168
- return domainHash('p9390812898200217364-yoloop-session-v1', session);
1169
- }
1170
- export function buildYoloopSessionRolloverPayloadHash(operation) {
1171
- return domainHash('p9390812898200217364-yoloop-rollover-v1', operation);
1172
- }
1173
- export function buildYoloopSuccessorRunPayloadHash(operation) {
1174
- return domainHash('p9390812898200217364-yoloop-successor-run-v1', operation);
1175
- }
1176
- export function buildYoloopSuccessorTransitionPayloadHash(operation) {
1177
- return domainHash('p9046281030721413419-yoloop-successor-transition-v1', operation);
1178
- }
1179
- function sameSuccessorTransitionIdentity(left, right) {
1180
- return left.operationId === right.operationId
1181
- && left.idempotencyKey === right.idempotencyKey;
1182
- }
1183
- function validateBoundedSuccessorTransition(operation) {
1184
- const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1185
- validateBoundedSuccessorRunOperation(operation.successor);
1186
- const base = {
1187
- operationId: operation.operationId,
1188
- idempotencyKey: operation.idempotencyKey,
1189
- successor: operation.successor,
1190
- state: operation.state,
1191
- ...(operation.acknowledgement ? { acknowledgement: operation.acknowledgement } : {}),
1192
- ...(operation.reservation ? { reservation: operation.reservation } : {}),
1193
- };
1194
- const acknowledgementKey = operation.acknowledgement?.idempotencyKey;
1195
- const acknowledgementReceiptKey = operation.acknowledgement?.receipt.idempotencyKey;
1196
- const reservationKey = operation.reservation?.authority.idempotencyKey;
1197
- if (operation.successor.type !== 'start_successor_run'
1198
- || !boundedId.test(operation.operationId)
1199
- || !boundedId.test(operation.idempotencyKey)
1200
- || operation.operationId !== operation.successor.operationId
1201
- || operation.idempotencyKey !== operation.successor.idempotencyKey
1202
- || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1203
- || operation.payloadHash !== buildYoloopSuccessorTransitionPayloadHash(base)
1204
- || Buffer.byteLength(stablePublicJson(base), 'utf8') > 1_048_576
1205
- || (operation.acknowledgement !== undefined && (operation.acknowledgement.type !== 'acknowledge_continuation_authority'
1206
- || typeof acknowledgementKey !== 'string'
1207
- || !boundedId.test(acknowledgementKey)
1208
- || acknowledgementReceiptKey !== acknowledgementKey))
1209
- || (operation.reservation !== undefined && (operation.reservation.type !== 'reserve_continuation_authority'
1210
- || typeof reservationKey !== 'string'
1211
- || !boundedId.test(reservationKey)))
1212
- || (acknowledgementKey !== undefined && reservationKey === acknowledgementKey))
1213
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_CONFLICT');
1214
- }
1215
- function validateSuccessorTransitionBeforeState(operation, current) {
1216
- validateBoundedSuccessorTransition(operation);
1217
- const successorAlreadyApplied = stablePublicJson(current.runControl)
1218
- === stablePublicJson(operation.successor.next);
1219
- if (!successorAlreadyApplied) {
1220
- const predecessor = current.runControl;
1221
- if (!predecessor
1222
- || predecessor.runId !== operation.successor.previousRunId
1223
- || !['exhausted', 'success'].includes(predecessor.status)
1224
- || predecessor.revision !== operation.successor.expectedRevision
1225
- || predecessor.latestSnapshotHash !== operation.successor.expectedSnapshotHash
1226
- || current.currentSession !== null)
1227
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT');
1228
- }
1229
- validateSuccessorTransitionAuthorityProjection(operation, current);
1230
- }
1231
- function validateSuccessorTransitionAuthorityProjection(operation, current) {
1232
- const pending = current.pendingContinuationAuthorities ?? [];
1233
- const receipts = current.continuationReceipts ?? [];
1234
- const acknowledgement = operation.acknowledgement;
1235
- if (acknowledgement) {
1236
- const authority = pending.find((item) => item.idempotencyKey === acknowledgement.idempotencyKey);
1237
- if (!authority
1238
- || acknowledgement.receipt.sessionId !== authority.sessionId
1239
- || stablePublicJson(acknowledgement.receipt.storyIds) !== stablePublicJson(authority.storyIds))
1240
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1241
- const projectedPending = pending.filter((item) => item.idempotencyKey !== acknowledgement.idempotencyKey);
1242
- const projectedReceipts = [...receipts.slice(-63), acknowledgement.receipt];
1243
- if (stablePublicJson(operation.state.pendingContinuationAuthorities ?? [])
1244
- !== stablePublicJson(projectedPending)
1245
- || stablePublicJson(operation.state.continuationReceipts ?? [])
1246
- !== stablePublicJson(projectedReceipts))
1247
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1248
- }
1249
- else if (stablePublicJson(operation.state.pendingContinuationAuthorities ?? []) !== stablePublicJson(pending)
1250
- || stablePublicJson(operation.state.continuationReceipts ?? []) !== stablePublicJson(receipts)) {
1251
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1252
- }
1253
- const reservation = operation.reservation?.authority;
1254
- if (!reservation)
1255
- return;
1256
- const key = String(reservation.idempotencyKey ?? '');
1257
- const projected = operation.state.pendingContinuationAuthorities ?? [];
1258
- const existing = projected.find((item) => item.idempotencyKey === key);
1259
- if (existing && stablePublicJson(existing) !== stablePublicJson(reservation)) {
1260
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1261
- }
1262
- if (!existing && projected.length >= 64) {
1263
- throw new Error('YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED');
1264
- }
1265
- }
1266
- function buildSuccessorTransitionState(operation, current) {
1267
- validateSuccessorTransitionBeforeState(operation, current);
1268
- const reservation = operation.reservation?.authority;
1269
- const projected = [...(operation.state.pendingContinuationAuthorities ?? [])];
1270
- if (reservation && !projected.some((item) => item.idempotencyKey === reservation.idempotencyKey)) {
1271
- projected.push(reservation);
1272
- }
1273
- return {
1274
- ...operation.state,
1275
- version: '1.2',
1276
- runControl: operation.successor.next,
1277
- ...(reservation || operation.state.pendingContinuationAuthorities !== undefined
1278
- ? { pendingContinuationAuthorities: projected }
1279
- : {}),
1280
- };
1281
- }
1282
- function successorTransitionApplied(operation, current) {
1283
- const reservation = operation.reservation?.authority;
1284
- const projected = [...(operation.state.pendingContinuationAuthorities ?? [])];
1285
- if (reservation && !projected.some((item) => item.idempotencyKey === reservation.idempotencyKey)) {
1286
- projected.push(reservation);
1287
- }
1288
- const expected = {
1289
- ...operation.state,
1290
- version: '1.2',
1291
- runControl: operation.successor.next,
1292
- ...(reservation || operation.state.pendingContinuationAuthorities !== undefined
1293
- ? { pendingContinuationAuthorities: projected }
1294
- : {}),
1295
- };
1296
- return stablePublicJson(current) === stablePublicJson(expected);
1297
- }
1298
- function sameRolloverIdentity(left, right) {
1299
- return left.operationId === right.operationId
1300
- && left.idempotencyKey === right.idempotencyKey;
1301
- }
1302
- function validateBoundedSessionRollover(operation) {
1303
- const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1304
- const base = {
1305
- runId: operation.runId,
1306
- operationId: operation.operationId,
1307
- idempotencyKey: operation.idempotencyKey,
1308
- expectedRevision: operation.expectedRevision,
1309
- expectedSnapshotHash: operation.expectedSnapshotHash,
1310
- expectedSessionId: operation.expectedSessionId,
1311
- expectedSessionFingerprint: operation.expectedSessionFingerprint,
1312
- expectedLeaseId: operation.expectedLeaseId,
1313
- coolDownUntil: operation.coolDownUntil,
1314
- reason: operation.reason,
1315
- exhaustedRun: operation.exhaustedRun,
1316
- next: operation.next,
1317
- };
1318
- if (!boundedId.test(operation.runId)
1319
- || !boundedId.test(operation.operationId)
1320
- || !boundedId.test(operation.idempotencyKey)
1321
- || !/^[a-f0-9]{64}$/.test(operation.expectedSessionFingerprint)
1322
- || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1323
- || !Number.isSafeInteger(operation.expectedRevision)
1324
- || operation.expectedRevision < 0
1325
- || !Number.isFinite(Date.parse(operation.coolDownUntil))
1326
- || !['YOLOOP_PER_RUN_CAP_REACHED', 'YOLOOP_SESSION_TIMEOUT'].includes(operation.reason)
1327
- || operation.payloadHash !== buildYoloopSessionRolloverPayloadHash(base)
1328
- || operation.receipt.schemaVersion !== 'YoloopSessionRolloverReceipt/1.0'
1329
- || operation.receipt.runId !== operation.runId
1330
- || operation.receipt.operationId !== operation.operationId
1331
- || operation.receipt.idempotencyKey !== operation.idempotencyKey
1332
- || operation.receipt.payloadHash !== operation.payloadHash
1333
- || operation.receipt.coolDownUntil !== operation.coolDownUntil
1334
- || operation.receipt.revision !== (operation.exhaustedRun?.revision ?? 0)
1335
- || operation.receipt.snapshotHash !== (operation.exhaustedRun?.latestSnapshotHash ?? null)
1336
- || operation.receipt.status !== 'exhausted'
1337
- || operation.receipt.publicSafe !== true)
1338
- throw new Error('YOLOOP_SESSION_ROLLOVER_CONFLICT');
1339
- if ((operation.expectedSessionId === null) !== (operation.expectedLeaseId === null)
1340
- || (operation.expectedSessionId !== null && !boundedId.test(operation.expectedSessionId))
1341
- || (operation.expectedLeaseId !== null && !boundedId.test(operation.expectedLeaseId)))
1342
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
1343
- }
1344
- function validateBoundedSuccessorRunOperation(operation) {
1345
- const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1346
- const base = {
1347
- runId: operation.runId,
1348
- previousRunId: operation.previousRunId,
1349
- operationId: operation.operationId,
1350
- idempotencyKey: operation.idempotencyKey,
1351
- expectedRevision: operation.expectedRevision,
1352
- expectedSnapshotHash: operation.expectedSnapshotHash,
1353
- next: operation.next,
1354
- };
1355
- if (!boundedId.test(operation.runId)
1356
- || !boundedId.test(operation.previousRunId)
1357
- || operation.runId === operation.previousRunId
1358
- || !boundedId.test(operation.operationId)
1359
- || !boundedId.test(operation.idempotencyKey)
1360
- || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1361
- || !Number.isSafeInteger(operation.expectedRevision)
1362
- || operation.expectedRevision < 1
1363
- || operation.payloadHash !== buildYoloopSuccessorRunPayloadHash(base))
1364
- throw new Error('YOLOOP_RUN_SUCCESSOR_CONFLICT');
1365
- }
1366
- function validateSessionRolloverBeforeState(operation, current, lock) {
1367
- validateBoundedSessionRollover(operation);
1368
- const session = current.currentSession;
1369
- if (!session || fingerprintYoloopSession(session) !== operation.expectedSessionFingerprint) {
1370
- throw new Error('YOLOOP_SESSION_ROLLOVER_SESSION_CONFLICT');
1371
- }
1372
- const currentRun = current.runControl;
1373
- if ((currentRun?.revision ?? 0) !== operation.expectedRevision
1374
- || (currentRun?.latestSnapshotHash ?? null) !== operation.expectedSnapshotHash
1375
- || (currentRun && currentRun.runId !== operation.runId))
1376
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
1377
- if (operation.expectedSessionId === null || operation.expectedLeaseId === null) {
1378
- if (lock)
1379
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
1380
- }
1381
- else if (!lock
1382
- || lock.sessionId !== operation.expectedSessionId
1383
- || lock.leaseId !== operation.expectedLeaseId) {
1384
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
1385
- }
1386
- const nextRun = operation.exhaustedRun;
1387
- if (!currentRun) {
1388
- const createdReceipt = nextRun?.receipts[0];
1389
- const exhaustedReceipt = nextRun?.receipts[1];
1390
- if (nextRun && (nextRun.runId !== operation.runId
1391
- || nextRun.status !== 'exhausted'
1392
- || nextRun.revision !== 2
1393
- || nextRun.events.length !== 2
1394
- || nextRun.progressSnapshots.length !== 4
1395
- || nextRun.receipts.length !== 2
1396
- || createdReceipt?.runId !== operation.runId
1397
- || createdReceipt.revision !== 1
1398
- || createdReceipt.status !== 'created'
1399
- || exhaustedReceipt?.runId !== operation.runId
1400
- || exhaustedReceipt.revision !== 2
1401
- || exhaustedReceipt.status !== 'exhausted'
1402
- || exhaustedReceipt.operationId !== `${operation.operationId}:run`
1403
- || exhaustedReceipt.idempotencyKey !== `${operation.idempotencyKey}:run`
1404
- || nextRun.latestSnapshotHash !== exhaustedReceipt.snapshotHash
1405
- || nextRun.predecessor !== undefined))
1406
- throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1407
- }
1408
- else if (currentRun.status === 'exhausted') {
1409
- if (stablePublicJson(nextRun) !== stablePublicJson(currentRun)) {
1410
- throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1411
- }
1412
- }
1413
- else {
1414
- if (!nextRun || nextRun.status !== 'exhausted') {
1415
- throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1416
- }
1417
- const receipt = nextRun.receipts.at(-1);
1418
- if (!receipt)
1419
- throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1420
- const transition = {
1421
- type: 'transition_run',
1422
- runId: currentRun.runId,
1423
- operationId: receipt.operationId,
1424
- idempotencyKey: receipt.idempotencyKey,
1425
- expectedRevision: currentRun.revision,
1426
- expectedSnapshotHash: currentRun.latestSnapshotHash,
1427
- payloadHash: receipt.payloadHash,
1428
- next: nextRun,
1429
- };
1430
- validateBoundedRunOperation(transition);
1431
- validateProposedRunTransition(currentRun, transition);
1432
- }
1433
- const expectedNext = {
1434
- ...current,
1435
- version: '1.2',
1436
- coolDownUntil: operation.coolDownUntil,
1437
- currentSession: null,
1438
- ...(nextRun ? { runControl: nextRun } : {}),
1439
- };
1440
- if (stablePublicJson(expectedNext) !== stablePublicJson(operation.next)) {
1441
- throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1442
- }
1443
- }
1444
- export function buildYoloopRunTransitionPayloadHash(input) {
1445
- const { receipts: _receipts, ...nextWithoutReceipts } = input.next;
1446
- return domainHash('p220-yoloop-run-transition-payload-v1', {
1447
- runId: input.runId,
1448
- operationId: input.operationId,
1449
- idempotencyKey: input.idempotencyKey,
1450
- expectedRevision: input.expectedRevision,
1451
- expectedSnapshotHash: input.expectedSnapshotHash,
1452
- next: nextWithoutReceipts,
1453
- });
1454
- }
1455
- const TERMINAL_RUN_STATUSES = new Set([
1456
- 'success', 'partial', 'blocked', 'failure', 'exhausted', 'cancelled',
1457
- ]);
1458
- const RUN_EDGES = {
1459
- created: ['running', 'cancelling', 'exhausted'],
1460
- running: ['pausing', 'cancelling', 'success', 'partial', 'blocked', 'failure', 'exhausted'],
1461
- pausing: ['paused', 'cancelling', 'failure', 'exhausted'],
1462
- paused: ['resuming', 'cancelling', 'exhausted'],
1463
- resuming: ['running', 'cancelling', 'failure', 'exhausted'],
1464
- cancelling: ['cancelled', 'failure', 'exhausted'],
1465
- };
1466
- function localLifecycleRequestTarget(status, action) {
1467
- if (action === 'pause')
1468
- return status === 'running' ? 'pausing' : null;
1469
- if (action === 'resume')
1470
- return status === 'paused' ? 'resuming' : null;
1471
- return TERMINAL_RUN_STATUSES.has(status) || status === 'cancelling' ? null : 'cancelling';
1472
- }
1473
- function buildLocalLifecycleOperation(input) {
1474
- const revision = input.current.revision + 1;
1475
- const reasonCodes = input.action === 'cancel' ? ['YOLOOP_RUN_CANCEL_REQUESTED'] : [];
1476
- const budget = {
1477
- schemaVersion: 'YoloopOperationalBudgetSummary/1.0',
1478
- limits: input.current.operationalBudget.limits,
1479
- usage: input.current.usage,
1480
- exhaustedDimension: null,
1481
- };
1482
- const beforeBase = {
1483
- schemaVersion: 'ProgressSnapshot/1.0',
1484
- runId: input.current.runId,
1485
- sequence: input.current.progressSnapshots.length + 1,
1486
- revision,
1487
- operationId: input.operationId,
1488
- boundary: input.action,
1489
- phase: 'before',
1490
- status: input.current.status,
1491
- occurredAt: input.occurredAt,
1492
- parentSnapshotHash: input.current.latestSnapshotHash,
1493
- budget,
1494
- reasonCodes,
1495
- publicSafe: true,
1496
- };
1497
- const beforeId = domainHash('p220-yoloop-progress-snapshot-id-v1', {
1498
- runId: beforeBase.runId, operationId: beforeBase.operationId,
1499
- sequence: beforeBase.sequence, phase: beforeBase.phase,
1500
- });
1501
- const before = {
1502
- ...beforeBase,
1503
- snapshotId: beforeId,
1504
- snapshotHash: domainHash('p220-yoloop-progress-snapshot-v1', { ...beforeBase, snapshotId: beforeId }),
1505
- };
1506
- const afterBase = {
1507
- ...beforeBase,
1508
- sequence: before.sequence + 1,
1509
- phase: 'after',
1510
- status: input.target,
1511
- parentSnapshotHash: before.snapshotHash,
1512
- };
1513
- const afterId = domainHash('p220-yoloop-progress-snapshot-id-v1', {
1514
- runId: afterBase.runId, operationId: afterBase.operationId,
1515
- sequence: afterBase.sequence, phase: afterBase.phase,
1516
- });
1517
- const after = {
1518
- ...afterBase,
1519
- snapshotId: afterId,
1520
- snapshotHash: domainHash('p220-yoloop-progress-snapshot-v1', { ...afterBase, snapshotId: afterId }),
1521
- };
1522
- const eventBase = {
1523
- schemaVersion: 'YoloopRunEvent/1.0',
1524
- runId: input.current.runId,
1525
- operationId: input.operationId,
1526
- kind: 'intent',
1527
- sequence: input.current.events.length + 1,
1528
- revision,
1529
- occurredAt: input.occurredAt,
1530
- fromStatus: input.current.status,
1531
- toStatus: input.target,
1532
- reasonCodes,
1533
- budgetDimension: null,
1534
- parentEventHash: input.current.events.at(-1)?.eventHash ?? null,
1535
- publicSafe: true,
1536
- };
1537
- const eventId = domainHash('p220-yoloop-run-event-id-v1', {
1538
- runId: eventBase.runId, operationId: eventBase.operationId, sequence: eventBase.sequence,
1539
- });
1540
- const event = {
1541
- ...eventBase,
1542
- eventId,
1543
- eventHash: domainHash('p220-yoloop-run-event-v1', { ...eventBase, eventId }),
1544
- };
1545
- const nextWithoutReceipt = {
1546
- ...input.current,
1547
- revision,
1548
- status: input.target,
1549
- events: [...input.current.events, event],
1550
- progressSnapshots: [...input.current.progressSnapshots, before, after],
1551
- latestSnapshotHash: after.snapshotHash,
1552
- receipts: [...input.current.receipts],
1553
- };
1554
- const base = {
1555
- runId: input.current.runId,
1556
- operationId: input.operationId,
1557
- idempotencyKey: input.idempotencyKey,
1558
- expectedRevision: input.current.revision,
1559
- expectedSnapshotHash: input.current.latestSnapshotHash,
1560
- next: nextWithoutReceipt,
1561
- };
1562
- const payloadHash = buildYoloopRunTransitionPayloadHash(base);
1563
- const receipt = {
1564
- schemaVersion: 'YoloopRunTransitionReceipt/1.0',
1565
- runId: input.current.runId,
1566
- operationId: input.operationId,
1567
- idempotencyKey: input.idempotencyKey,
1568
- payloadHash,
1569
- revision,
1570
- snapshotHash: after.snapshotHash,
1571
- status: input.target,
1572
- };
1573
- return {
1574
- type: 'transition_run', ...base, payloadHash,
1575
- next: { ...nextWithoutReceipt, receipts: [...nextWithoutReceipt.receipts, receipt] },
1576
- };
1577
- }
1578
- function validateBoundedRunOperation(operation) {
1579
- const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1580
- if (!boundedId.test(operation.runId)
1581
- || !boundedId.test(operation.operationId)
1582
- || !boundedId.test(operation.idempotencyKey)
1583
- || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1584
- || !Number.isSafeInteger(operation.expectedRevision)
1585
- || operation.expectedRevision < 0)
1586
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1587
- if (operation.payloadHash !== buildYoloopRunTransitionPayloadHash(operation)) {
1588
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1589
- }
1590
- }
1591
- function validateProposedRunTransition(current, operation) {
1592
- const next = operation.next;
1593
- const currentEvents = current?.events ?? [];
1594
- const currentSnapshots = current?.progressSnapshots ?? [];
1595
- const currentReceipts = current?.receipts ?? [];
1596
- if (next.schemaVersion !== 'YoloopRunControl/1.0'
1597
- || next.runId !== operation.runId
1598
- || next.revision !== operation.expectedRevision + 1
1599
- || next.events.length !== currentEvents.length + 1
1600
- || next.progressSnapshots.length !== currentSnapshots.length + 2
1601
- || next.receipts.length !== currentReceipts.length + 1
1602
- || stablePublicJson(next.events.slice(0, currentEvents.length)) !== stablePublicJson(currentEvents)
1603
- || stablePublicJson(next.progressSnapshots.slice(0, currentSnapshots.length)) !== stablePublicJson(currentSnapshots)
1604
- || stablePublicJson(next.receipts.slice(0, currentReceipts.length)) !== stablePublicJson(currentReceipts))
1605
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1606
- if (current) {
1607
- if (TERMINAL_RUN_STATUSES.has(current.status))
1608
- throw new Error('YOLOOP_RUN_TERMINAL');
1609
- const proposedBoundary = next.progressSnapshots.at(-2)?.boundary;
1610
- const boundaryOnly = current.status === next.status
1611
- && (proposedBoundary === 'budget' || proposedBoundary === 'retry');
1612
- if (!(RUN_EDGES[current.status] ?? []).includes(next.status) && !boundaryOnly) {
1613
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1614
- }
1615
- if (current.startedAt !== next.startedAt || current.deadlineAt !== next.deadlineAt) {
1616
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1617
- }
1618
- if (stablePublicJson(current.predecessor) !== stablePublicJson(next.predecessor)) {
1619
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1620
- }
1621
- }
1622
- else if (next.status !== 'created') {
1623
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1624
- }
1625
- const before = next.progressSnapshots.at(-2);
1626
- const after = next.progressSnapshots.at(-1);
1627
- const event = next.events.at(-1);
1628
- const receipt = next.receipts.at(-1);
1629
- if (!before || !after || !event || !receipt
1630
- || before.phase !== 'before' || after.phase !== 'after'
1631
- || before.operationId !== operation.operationId || after.operationId !== operation.operationId
1632
- || event.operationId !== operation.operationId
1633
- || before.runId !== operation.runId || after.runId !== operation.runId || event.runId !== operation.runId
1634
- || before.revision !== next.revision || after.revision !== next.revision || event.revision !== next.revision
1635
- || before.parentSnapshotHash !== (current?.latestSnapshotHash ?? null)
1636
- || after.parentSnapshotHash !== before.snapshotHash
1637
- || next.latestSnapshotHash !== after.snapshotHash
1638
- || receipt.schemaVersion !== 'YoloopRunTransitionReceipt/1.0'
1639
- || receipt.runId !== operation.runId
1640
- || receipt.operationId !== operation.operationId
1641
- || receipt.idempotencyKey !== operation.idempotencyKey
1642
- || receipt.payloadHash !== operation.payloadHash
1643
- || receipt.revision !== next.revision
1644
- || receipt.snapshotHash !== next.latestSnapshotHash
1645
- || receipt.status !== next.status
1646
- || Date.parse(next.deadlineAt) <= Date.parse(next.startedAt))
1647
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1648
- validateSnapshotHash(before);
1649
- validateSnapshotHash(after);
1650
- validateEventHash(event, currentEvents.at(-1)?.eventHash ?? null);
1651
- }
1652
- function validateSnapshotHash(snapshot) {
1653
- const { snapshotHash, ...hashed } = snapshot;
1654
- if (snapshotHash !== domainHash('p220-yoloop-progress-snapshot-v1', hashed)) {
1655
- throw new Error('YOLOOP_RUN_SNAPSHOT_CONFLICT');
1656
- }
1657
- }
1658
- function validateEventHash(event, expectedParent) {
1659
- const { eventHash, ...hashed } = event;
1660
- if (event.parentEventHash !== expectedParent
1661
- || eventHash !== domainHash('p220-yoloop-run-event-v1', hashed))
1662
- throw new Error('YOLOOP_RUN_SNAPSHOT_CONFLICT');
1663
- }
1664
- function buildPublicLeaseId(sessionId, nowIso) {
1665
- return `lease-${publicStableHash(`${sessionId}:${nowIso}`)}`;
1666
- }
1667
- function publicStableHash(value) {
1668
- let hash = 0x811c9dc5;
1669
- for (let i = 0; i < value.length; i += 1) {
1670
- hash ^= value.charCodeAt(i);
1671
- hash = Math.imul(hash, 0x01000193) >>> 0;
1672
- }
1673
- return hash.toString(36).padStart(7, '0');
1674
- }
1675
- function classifyClientLease(lock, nowIso) {
1676
- const expiresAt = lock.leaseExpiresAt ?? lock.ttlExpiresAt;
1677
- const expiresMs = Date.parse(expiresAt);
1678
- const nowMs = Date.parse(nowIso);
1679
- if (!Number.isFinite(expiresMs) || !Number.isFinite(nowMs))
1680
- return 'unknown';
1681
- return expiresMs <= nowMs ? 'expired' : 'active';
1682
- }
1683
- // ── Pure merge helper (used by readState) ───────────────────────────────
1684
- function mergeCountersWithDefaults(parsed, nowIso) {
1685
- const defaults = createDefaultCounters(nowIso);
1686
- return {
1687
- // Preserve additive/unknown fields byte-semantically across read/write cycles.
1688
- ...parsed,
1689
- version: parsed.version === '1.2' ? '1.2' : '1.1',
1690
- lastResetAt: parsed.lastResetAt ?? defaults.lastResetAt,
1691
- storiesRunToday: parsed.storiesRunToday ?? 0,
1692
- iterationsToday: parsed.iterationsToday ?? 0,
1693
- iterationsTodayDate: parsed.iterationsTodayDate ?? defaults.iterationsTodayDate,
1694
- tokensUsedToday: parsed.tokensUsedToday ?? 0,
1695
- lastRunAt: parsed.lastRunAt ?? null,
1696
- coolDownUntil: parsed.coolDownUntil ?? null,
1697
- totalRunsAllTime: parsed.totalRunsAllTime ?? 0,
1698
- totalStoriesAllTime: parsed.totalStoriesAllTime ?? 0,
1699
- currentSession: parsed.currentSession ?? null,
1700
- ...(parsed.runControl !== undefined ? { runControl: parsed.runControl } : {}),
1701
- ...(parsed.continuationPolicy !== undefined ? { continuationPolicy: parsed.continuationPolicy } : {}),
1702
- ...(parsed.continuationSession !== undefined ? { continuationSession: parsed.continuationSession } : {}),
1703
- ...(parsed.pendingContinuationAuthorities !== undefined ? { pendingContinuationAuthorities: parsed.pendingContinuationAuthorities } : {}),
1704
- ...(parsed.continuationReceipts !== undefined ? { continuationReceipts: parsed.continuationReceipts } : {}),
1705
- ...(parsed.continuationRecoveryReceipts !== undefined ? { continuationRecoveryReceipts: parsed.continuationRecoveryReceipts } : {}),
1706
- ...(parsed.continuationReplanReceipts !== undefined ? { continuationReplanReceipts: parsed.continuationReplanReceipts } : {}),
1707
- // P104.06: preserve recovery_history (defensive default to [])
1708
- recovery_history: Array.isArray(parsed.recovery_history)
1709
- ? capRecoveryHistory(parsed.recovery_history)
1710
- : [],
1711
- };
1712
- }
1713
- // ── P104.06: pure FIFO cap helper (exported for tests) ─────────────────────
1714
- /**
1715
- * Enforce FIFO bound on `recovery_history[]`. Returns a new array of length
1716
- * <= `RECOVERY_HISTORY_CAP`, preserving the most recent entries (drops from
1717
- * head). Pure: input is not mutated.
1718
- */
1719
- export function capRecoveryHistory(history) {
1720
- if (history.length <= RECOVERY_HISTORY_CAP)
1721
- return history;
1722
- return history.slice(history.length - RECOVERY_HISTORY_CAP);
1723
- }
1
+ import{promises as u}from"node:fs";import{createHash as V}from"node:crypto";import{join as S,dirname as $}from"node:path";import{hostname as w}from"node:os";import{setSecureFilePermissions as q,setSecureDirPermissions as B}from"../config/secure-config.js";import{RECOVERY_HISTORY_CAP as m}from"./shared-yoloop-types.js";const j=".neocortex",g="yoloop-state.json",J="yoloop.lock",z="yoloop-state.mutation.lock",A="yoloop-session-rollover.json",P="yoloop-successor-transition.json",W=360*60*1e3,G=3e4,X=200,x=Object.freeze(["rollover-session-v1","successor-transition-v1","continuation-replan-v1"]),Z={warn:()=>{},info:()=>{},error:()=>{}};function f(t){const e=t.slice(0,10);return{version:"1.1",lastResetAt:t,storiesRunToday:0,iterationsToday:0,iterationsTodayDate:e,tokensUsedToday:0,lastRunAt:null,coolDownUntil:null,totalRunsAllTime:0,totalStoriesAllTime:0,currentSession:null,continuationPolicy:"p205-finite-resume-v2",pendingContinuationAuthorities:[],continuationReceipts:[],continuationRecoveryReceipts:[]}}function Re(t){return{lock:null,state:f(t),capabilities:x}}class ve{stateDir;statePath;lockPath;mutationLockPath;rolloverManifestPath;successorTransitionManifestPath;logger;constructor(e,s=Z){this.stateDir=S(e,j),this.statePath=S(this.stateDir,g),this.lockPath=S(this.stateDir,J),this.mutationLockPath=S(this.stateDir,z),this.rolloverManifestPath=S(this.stateDir,A),this.successorTransitionManifestPath=S(this.stateDir,P),this.logger=s}async readAll(e){const s=e??new Date().toISOString();await this.cleanupOrphanTempFiles(),await this.recoverSessionRolloverIfPresent(s),await this.recoverSuccessorTransitionIfPresent(s);const[n,o]=await Promise.all([this.readLock(),this.readState(s)]);return{lock:await this.prepareLockForSnapshot(n,s),state:o,capabilities:x}}async applyUpdate(e,s){const n=s??new Date().toISOString();await this.ensureDir(),await this.recoverSessionRolloverIfPresent(n),await this.recoverSuccessorTransitionIfPresent(n);for(const o of e.operations)await this.applyOperation(o,n)}async requestRunTransition(e,s){const n=s??new Date().toISOString();return await this.ensureDir(),this.withMutationLock(()=>this.transitionRun(e,n))}async requestLifecycleAction(e,s={},n){const o=n??new Date().toISOString();return await this.ensureDir(),this.withMutationLock(async()=>{const c=(await this.readState(o)).runControl;if(!c)throw new Error("YOLOOP_RUN_TRANSITION_INVALID");const a=Date.parse(o).toString(36),i=s.operationId??`op-${e}-${a}`,l=s.idempotencyKey??`idem-${e}-${a}`,h=c.receipts.find(y=>y.operationId===i||y.idempotencyKey===l);if(h){if(h.operationId===i&&h.idempotencyKey===l)return h;throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}const O=de(c.status,e);if(!O)throw new Error(T.has(c.status)?"YOLOOP_RUN_TERMINAL":"YOLOOP_RUN_TRANSITION_INVALID");return this.transitionRun(le({current:c,action:e,target:O,operationId:i,idempotencyKey:l,occurredAt:o}),o)})}async applyOperation(e,s){switch(e.type){case"acquire_lock":await this.withMutationLock(()=>this.acquireLock(e.lock,s));return;case"release_lock":await this.withMutationLock(()=>this.releaseLock(e));return;case"write_state":await this.withMutationLock(async()=>{const n=await this.readState(s),{runControl:o,...r}=e.state;await this.writeState({...r,...n.runControl?{version:"1.2",runControl:n.runControl}:{}})});return;case"advance_epic":await this.advanceEpic(e.nextEpicIndex,e.nextEpicId,s);return;case"no_op":this.logger.info({event:"yoloop_noop"},"yoloop server sent no-op");return;case"reserve_continuation_authority":await this.reserveContinuationAuthority(e.authority,s);return;case"acknowledge_continuation_authority":await this.acknowledgeContinuationAuthority(e.idempotencyKey,e.receipt,s);return;case"repair_continuation_authority":await this.repairContinuationAuthority(e.beforeHash,e.authority,e.receipt,s);return;case"switch_continuation_policy":await this.switchContinuationPolicy(e.policy,e.session,s);return;case"transition_run":await this.requestRunTransition(e,s);return;case"start_successor_run":await this.withMutationLock(()=>this.startSuccessorRun(e,s));return;case"commit_successor_transition":await this.withMutationLock(()=>this.commitSuccessorTransition(e,s));return;case"rollover_session":await this.withMutationLock(()=>this.rolloverSession(e,s));return;case"replan_continuation_session":await this.withMutationLock(()=>this.replanContinuationSession(e,s));return;case"recovery_attempted":await this.appendRecoveryAttempt(e.attempt,s);return;case"recovery_resolved":await this.resolveRecoveryAttempt(e.attempt_id,e.outcome,s);return;default:{const n=e;throw this.logger.error({event:"yoloop_unknown_op",op:n},"unknown yoloop operation rejected"),new Error("YOLOOP_STATE_OPERATION_UNSUPPORTED")}}}async reserveContinuationAuthority(e,s){const n=await this.readState(s),o=n.pendingContinuationAuthorities??[],r=typeof e.idempotencyKey=="string"?e.idempotencyKey:"";if(!r)throw new Error("YOLOOP_CONTINUATION_AUTHORITY_INVALID");const c=o.find(a=>a.idempotencyKey===r);if(c){if(d(c)===d(e))return;throw new Error("YOLOOP_CONTINUATION_AUTHORITY_CONFLICT")}if(o.length>=64)throw new Error("YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED");await this.writeState({...n,pendingContinuationAuthorities:[...o,e]})}async acknowledgeContinuationAuthority(e,s,n){const o=await this.readState(n),r=o.pendingContinuationAuthorities??[],c=r.findIndex(h=>h.idempotencyKey===e);if(c<0)throw new Error("YOLOOP_CONTINUATION_RECEIPT_ORPHAN");const a=r[c];if(s.idempotencyKey!==e||s.sessionId!==a.sessionId||JSON.stringify(s.storyIds)!==JSON.stringify(a.storyIds))throw new Error("YOLOOP_CONTINUATION_RECEIPT_MISMATCH");const i=r.filter((h,O)=>O!==c),l=o.continuationReceipts??[];await this.writeState({...o,pendingContinuationAuthorities:i,continuationReceipts:[...l.slice(-63),s]})}async repairContinuationAuthority(e,s,n,o){const r=await this.readState(o),c=r.pendingContinuationAuthorities??[],a=r.continuationRecoveryReceipts??[],i=typeof n.idempotencyKey=="string"?n.idempotencyKey:"",l=a.find(y=>y.idempotencyKey===i);if(l){if(d(l)===d(n))return;throw new Error("YOLOOP_CONTINUATION_RECOVERY_RECEIPT_CONFLICT")}if(c.length!==1||p("p208-legacy-authority-before-v1",c[0])!==e)throw new Error("YOLOOP_CONTINUATION_RECOVERY_BEFORE_MISMATCH");const h=p("p208-legacy-authority-after-v1",s);if(n.beforeHash!==e||n.afterHash!==h)throw new Error("YOLOOP_CONTINUATION_RECOVERY_HASH_MISMATCH");const O={...n};if(delete O.idempotencyKey,!i||p("p208-legacy-authority-recovery-receipt-v1",O)!==i)throw new Error("YOLOOP_CONTINUATION_RECOVERY_RECEIPT_INVALID");await this.writeState({...r,pendingContinuationAuthorities:[s],continuationRecoveryReceipts:[...a.slice(-63),n]})}async switchContinuationPolicy(e,s,n){const o=await this.readState(n);if(o.currentSession||(o.pendingContinuationAuthorities?.length??0)>0)throw new Error("YOLOOP_POLICY_SWITCH_ACTIVE");try{await u.copyFile(this.statePath,`${this.statePath}.policy-backup`)}catch(r){if(r.code!=="ENOENT")throw r}await this.writeState({...o,continuationPolicy:e,continuationSession:s})}async replanContinuationSession(e,s){const{type:n,payloadHash:o,receipt:r,...c}=e;if(Q(c)!==e.payloadHash||!k(e.nextSession))throw new Error("YOLOOP_CONTINUATION_REPLAN_PAYLOAD_INVALID");const a={schemaVersion:"YoloopContinuationReplanReceipt/1.0",operationId:e.operationId,idempotencyKey:e.idempotencyKey,runId:e.expectedRunId,previousSessionHash:e.expectedSessionHash,nextSessionHash:p("p222-yoloop-replan-session-v1",e.nextSession),retiredAuthorityHash:e.expectedPendingHash,retiredAuthorityCount:e.expectedPendingHash===p("p222-yoloop-replan-pending-v1",[])?0:1,payloadHash:e.payloadHash,outcome:"graph-replanned",publicSafe:!0};if(d(e.receipt)!==d(a))throw new Error("YOLOOP_CONTINUATION_REPLAN_RECEIPT_INVALID");const i=await this.readState(s);if(await this.readLock()||i.currentSession!==null)throw new Error("YOLOOP_CONTINUATION_REPLAN_ACTIVE");const l=i.runControl;if(!l||l.runId!==e.expectedRunId||!["success","exhausted"].includes(l.status)||l.revision!==e.expectedRevision||l.latestSnapshotHash!==e.expectedSnapshotHash||i.continuationPolicy!=="p205-finite-resume-v2")throw new Error("YOLOOP_CONTINUATION_REPLAN_RUN_CONFLICT");const h=i.continuationReplanReceipts??[],O=h.find(_=>_.idempotencyKey===e.idempotencyKey||_.operationId===e.operationId);if(O){if(d(O)===d(a)&&d(i.continuationSession)===d(e.nextSession)&&(i.pendingContinuationAuthorities?.length??0)===0)return;throw new Error("YOLOOP_CONTINUATION_REPLAN_REPLAY_CONFLICT")}const y=i.continuationSession,I=i.pendingContinuationAuthorities??[];if(!k(y)||p("p222-yoloop-replan-session-v1",y)!==e.expectedSessionHash||p("p222-yoloop-replan-pending-v1",I)!==e.expectedPendingHash||I.length>1||I.some(_=>_.delegated!==!1||_.sessionId!==y.sessionId||!_.certificate||_.certificate.sessionId!==y.sessionId))throw new Error("YOLOOP_CONTINUATION_REPLAN_AUTHORITY_CONFLICT");await this.writeState({...i,continuationSession:e.nextSession,pendingContinuationAuthorities:[],continuationReplanReceipts:[...h.slice(-63),a]})}async appendRecoveryAttempt(e,s){const n=await this.readState(s),o=n.recovery_history??[];if(o.some(i=>i.attempted_at===e.attempted_at&&i.story_id===e.story_id)){this.logger.warn({event:"recovery_attempted_duplicate",story_id:e.story_id},"duplicate recovery_attempted op; skipping");return}const c=M([...o,e]),a={...n,recovery_history:c};await this.writeState(a),this.logger.info({event:"recovery_attempted_persisted",story_id:e.story_id,history_size:c.length},"recovery attempt persisted")}async resolveRecoveryAttempt(e,s,n){const o=await this.readState(n),r=o.recovery_history??[],c=r.findIndex(h=>h.recovery_step_id_tokenized===e);if(c===-1){this.logger.warn({event:"recovery_resolve_orphan",attempt_id:e},"recovery_resolved op had no matching attempt; skipping");return}const a=r[c];if(a.outcome===s&&a.finished_at){this.logger.info({event:"recovery_resolved_duplicate",attempt_id:e,outcome:s},"duplicate recovery_resolved op; skipping");return}const i=r.slice();i[c]={...a,outcome:s,finished_at:n};const l={...o,recovery_history:i};await this.writeState(l),this.logger.info({event:"recovery_resolved_persisted",attempt_id:e,outcome:s},"recovery attempt resolved")}async acquireLock(e,s){const n={...e,pid:process.pid,hostname:w(),ownerKind:"client",startedAt:e.startedAt||s,leaseId:e.leaseId??he(e.sessionId,s),leaseState:"active",leaseAcquiredAt:e.leaseAcquiredAt??s,leaseDurationMs:e.leaseDurationMs??W,leaseExpiresAt:e.leaseExpiresAt??e.ttlExpiresAt},o=JSON.stringify(n,null,2);try{const r=await u.open(this.lockPath,"wx");try{await r.writeFile(o,"utf8")}finally{await r.close()}this.trySetFilePerms(this.lockPath),this.logger.info({event:"yoloop_lock_acquired",pid:n.pid,sessionId:n.sessionId},"yoloop lock acquired");return}catch(r){const c=r;if(c.code!=="EEXIST")throw this.logger.error({event:"yoloop_lock_acquire_error",err:c.message},"failed to acquire yoloop lock"),r;const a=await this.readLock();if(a&&this.isSameClientSessionLock(a,n)){this.logger.info({event:"yoloop_lock_acquire_duplicate",sessionId:n.sessionId},"duplicate acquire_lock for current yoloop session; treating as no-op");return}if(!await this.maybeRecoverOrphanLock(s))throw this.logger.warn({event:"yoloop_lock_held"},"yoloop lock held by another live process"),r;const l=await u.open(this.lockPath,"wx");try{await l.writeFile(o,"utf8")}finally{await l.close()}this.trySetFilePerms(this.lockPath),this.logger.info({event:"yoloop_lock_acquired_after_recovery",pid:n.pid},"yoloop lock acquired after orphan recovery")}}async releaseLock(e){const s=await this.readState(new Date().toISOString()),n=await this.readLock();if(s.runControl){if(!e.expectedRunId||!e.expectedSessionId||!e.expectedLeaseId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if(e.expectedRunId!==s.runControl.runId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if(!n||e.expectedSessionId!==n.sessionId||e.expectedLeaseId!==n.leaseId)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}try{await u.unlink(this.lockPath),this.logger.info({event:"yoloop_lock_released"},"yoloop lock released")}catch(o){const r=o;if(r.code==="ENOENT"){this.logger.info({event:"yoloop_lock_release_noop"},"yoloop lock release noop (file absent)");return}this.logger.warn({event:"yoloop_lock_release_error",err:r.message},"unexpected error releasing yoloop lock")}}async readLock(){try{const e=await u.readFile(this.lockPath,"utf8"),s=JSON.parse(e),n=s.currentEpicId??s.epicId??"";return{pid:s.pid??0,hostname:s.hostname??"",...s.ownerKind?{ownerKind:s.ownerKind}:{},...s.ownerPidState?{ownerPidState:s.ownerPidState}:{},...typeof s.leaseId=="string"?{leaseId:s.leaseId}:{},...s.leaseState?{leaseState:s.leaseState}:{},...typeof s.leaseAcquiredAt=="string"?{leaseAcquiredAt:s.leaseAcquiredAt}:{},...typeof s.leaseDurationMs=="number"?{leaseDurationMs:s.leaseDurationMs}:{},...typeof s.leaseExpiresAt=="string"?{leaseExpiresAt:s.leaseExpiresAt}:{},startedAt:s.startedAt??"",sessionId:s.sessionId??"",mode:s.mode??"epic-id",inputPath:s.inputPath??"",epicQueue:s.epicQueue??(n?[n]:[]),currentEpicIndex:s.currentEpicIndex??0,currentEpicId:n,ttlExpiresAt:s.ttlExpiresAt??"",epicId:s.epicId??n}}catch{return null}}async readState(e){try{const s=await u.readFile(this.statePath,"utf8"),n=JSON.parse(s);return Oe(n,e)}catch(s){const n=s;return n.code!=="ENOENT"&&this.logger.warn({event:"yoloop_state_read_error",err:n.message},"yoloop state file unreadable; using defaults"),f(e)}}async writeState(e){const s=`${this.statePath}.tmp.${process.pid}.${Date.now()}`,n=JSON.stringify(e,null,2);try{const o=await u.open(s,"w",384);try{await o.writeFile(n,"utf8"),await o.sync()}finally{await o.close()}this.trySetFilePerms(s),await u.rename(s,this.statePath),await this.syncStateDirectory()}catch(o){const r=o;this.logger.error({event:"yoloop_state_write_error",err:r.message},"failed to write yoloop state");try{await u.unlink(s)}catch{}throw o}}async rolloverSession(e,s){E(e);const n=await this.readRolloverManifest();if(n){if(ae(n.operation,e)){if(n.operation.payloadHash!==e.payloadHash)throw new Error("YOLOOP_SESSION_ROLLOVER_CONFLICT");if(n.phase==="applied")return;await this.completePreparedSessionRollover(n.operation,s);return}else if(n.phase==="prepared")throw new Error("YOLOOP_SESSION_ROLLOVER_CONFLICT")}const o=await this.readState(s),r=await this.readLock();U(e,o,r),await this.writeRolloverManifest({schemaVersion:"YoloopSessionRolloverManifest/1.0",phase:"prepared",operation:e}),await this.completePreparedSessionRollover(e,s)}async completePreparedSessionRollover(e,s){const n=await this.readState(s);let o=d(n)===d(e.next);if(!o){const c=await this.readLock();U(e,n,c),await this.writeState(e.next),o=!0}const r=await this.readLock();if(e.expectedSessionId===null||e.expectedLeaseId===null){if(e.expectedSessionId!==null||e.expectedLeaseId!==null||r)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT")}else if(r){if(r.sessionId!==e.expectedSessionId||r.leaseId!==e.expectedLeaseId)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT");await u.unlink(this.lockPath),await this.syncStateDirectory()}else if(!o)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT");await this.writeRolloverManifest({schemaVersion:"YoloopSessionRolloverManifest/1.0",phase:"applied",operation:e}),this.logger.info({event:"yoloop_session_rollover_applied",runId:e.runId,operationId:e.operationId,coolDownUntil:e.coolDownUntil},"yoloop per-run-cap session rollover applied")}async recoverSessionRolloverIfPresent(e){try{await u.access(this.rolloverManifestPath)}catch(s){if(s.code==="ENOENT")return;throw s}await this.ensureDir(),await this.withMutationLock(async()=>{const s=await this.readRolloverManifest();s?.phase==="prepared"&&await this.completePreparedSessionRollover(s.operation,e)})}async readRolloverManifest(){try{const e=JSON.parse(await u.readFile(this.rolloverManifestPath,"utf8"));if(e.schemaVersion!=="YoloopSessionRolloverManifest/1.0"||e.phase!=="prepared"&&e.phase!=="applied"||!e.operation||e.operation.type!=="rollover_session")throw new Error("YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT");return E(e.operation),e}catch(e){if(e.code==="ENOENT")return null;throw e.message==="YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT"?e:new Error("YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT")}}async writeRolloverManifest(e){const s=`${this.rolloverManifestPath}.tmp.${process.pid}.${Date.now()}`,n=await u.open(s,"w",384);try{await n.writeFile(JSON.stringify(e,null,2),"utf8"),await n.sync()}finally{await n.close()}this.trySetFilePerms(s),await u.rename(s,this.rolloverManifestPath),await this.syncStateDirectory()}async commitSuccessorTransition(e,s){R(e);const n=await this.readSuccessorTransitionManifest();if(n){if(oe(n.operation,e)){if(n.operation.payloadHash!==e.payloadHash)throw new Error("YOLOOP_SUCCESSOR_TRANSITION_CONFLICT");if(n.phase==="applied")return;await this.completePreparedSuccessorTransition(n.operation,s);return}if(n.phase==="prepared")throw new Error("YOLOOP_SUCCESSOR_TRANSITION_CONFLICT")}const o=await this.readState(s);if(v(e,o)){await this.writeSuccessorTransitionManifest({schemaVersion:"YoloopSuccessorTransitionManifest/1.0",phase:"applied",operation:e});return}H(e,o),await this.writeSuccessorTransitionManifest({schemaVersion:"YoloopSuccessorTransitionManifest/1.0",phase:"prepared",operation:e}),await this.completePreparedSuccessorTransition(e,s)}async completePreparedSuccessorTransition(e,s){let n=await this.readState(s);if(!v(e,n)){d(n.runControl)===d(e.successor.next)||(await this.startSuccessorRun(e.successor,s),n=await this.readState(s));const r=ie(e,n);await this.writeState(r),n=await this.readState(s)}if(!v(e,n))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT");await this.writeSuccessorTransitionManifest({schemaVersion:"YoloopSuccessorTransitionManifest/1.0",phase:"applied",operation:e}),this.logger.info({event:"yoloop_successor_transition_applied",runId:e.successor.runId,operationId:e.operationId},"yoloop successor transition applied")}async recoverSuccessorTransitionIfPresent(e){try{await u.access(this.successorTransitionManifestPath)}catch(s){if(s.code==="ENOENT")return;throw s}await this.ensureDir(),await this.withMutationLock(async()=>{const s=await this.readSuccessorTransitionManifest();s?.phase==="prepared"&&await this.completePreparedSuccessorTransition(s.operation,e)})}async readSuccessorTransitionManifest(){try{const e=JSON.parse(await u.readFile(this.successorTransitionManifestPath,"utf8"));if(e.schemaVersion!=="YoloopSuccessorTransitionManifest/1.0"||e.phase!=="prepared"&&e.phase!=="applied"||!e.operation||e.operation.type!=="commit_successor_transition")throw new Error("YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT");return R(e.operation),e}catch(e){if(e.code==="ENOENT")return null;throw e.message==="YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT"?e:new Error("YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT")}}async writeSuccessorTransitionManifest(e){const s=`${this.successorTransitionManifestPath}.tmp.${process.pid}.${Date.now()}`,n=await u.open(s,"w",384);try{await n.writeFile(JSON.stringify(e,null,2),"utf8"),await n.sync()}finally{await n.close()}this.trySetFilePerms(s),await u.rename(s,this.successorTransitionManifestPath),await this.syncStateDirectory()}async advanceEpic(e,s,n){const o=await this.readState(n);if(!o.currentSession){this.logger.warn({event:"yoloop_advance_no_session",nextEpicId:s},"advance_epic with no active session; skipping");return}const r={...o,currentSession:{...o.currentSession,currentEpicIndex:e}};await this.writeState(r),this.logger.info({event:"yoloop_epic_advance",nextEpicId:s,nextIndex:e},"yoloop advanced to next epic")}async ensureDir(){try{await u.mkdir(this.stateDir,{recursive:!0}),this.trySetDirPerms(this.stateDir)}catch(e){const s=e;throw this.logger.error({event:"yoloop_mkdir_error",err:s.message,path:this.stateDir},"failed to ensure .neocortex/ directory"),e}}async syncStateDirectory(){try{const e=await u.open(this.stateDir,"r");try{await e.sync()}finally{await e.close()}}catch(e){const s=e.code;if(s!=="EINVAL"&&s!=="EISDIR"&&s!=="ENOTSUP"&&s!=="EPERM")throw e}}async withMutationLock(e){let s=null;for(let n=0;n<X;n+=1)try{s=await u.open(this.mutationLockPath,"wx",384),await s.writeFile(JSON.stringify({pid:process.pid,acquiredAt:new Date().toISOString()})),await s.sync();break}catch(o){if(o.code!=="EEXIST")throw o;try{const r=await u.stat(this.mutationLockPath);if(Date.now()-r.mtimeMs>G){await u.unlink(this.mutationLockPath);continue}}catch{continue}await new Promise(r=>setTimeout(r,5))}if(!s)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");try{return await e()}finally{await s.close().catch(()=>{}),await u.unlink(this.mutationLockPath).catch(()=>{})}}async transitionRun(e,s){N(e);const n=await this.readState(s),o=n.runControl,r=o?.receipts.find(l=>l.operationId===e.operationId||l.idempotencyKey===e.idempotencyKey);if(r){if(r.operationId===e.operationId&&r.idempotencyKey===e.idempotencyKey&&r.payloadHash===e.payloadHash)return r;throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}if(o&&o.runId!==e.runId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if((o?.revision??0)!==e.expectedRevision)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if((o?.latestSnapshotHash??null)!==e.expectedSnapshotHash)throw new Error("YOLOOP_RUN_SNAPSHOT_CONFLICT");L(o,e);const i=e.next.receipts.at(-1);if(!i)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT");return await this.writeState({...n,version:"1.2",runControl:e.next}),this.logger.info({event:"yoloop_run_transition_committed",runId:e.runId,operationId:e.operationId,revision:i.revision,status:i.status},"yoloop run transition committed"),i}async startSuccessorRun(e,s){Y(e);const n=await this.readState(s),o=n.runControl,r={schemaVersion:"YoloopRunPredecessor/1.0",runId:e.previousRunId,revision:e.expectedRevision,snapshotHash:e.expectedSnapshotHash,successorOperationId:e.operationId,publicSafe:!0},c=o?.receipts.find(l=>l.operationId===e.operationId||l.idempotencyKey===e.idempotencyKey);if(c){if(c.operationId===e.operationId&&c.idempotencyKey===e.idempotencyKey&&e.next.receipts.at(-1)?.payloadHash===c.payloadHash&&d(o?.predecessor)===d(r))return c;throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}if(!o||o.runId!==e.previousRunId||!["exhausted","success"].includes(o.status)||o.revision!==e.expectedRevision||o.latestSnapshotHash!==e.expectedSnapshotHash||n.currentSession!==null)throw new Error("YOLOOP_RUN_SUCCESSOR_CONFLICT");const a=e.next.receipts.at(-1);if(!a||d(e.next.predecessor)!==d(r))throw new Error("YOLOOP_RUN_SUCCESSOR_CONFLICT");const i={type:"transition_run",runId:e.runId,operationId:e.operationId,idempotencyKey:e.idempotencyKey,expectedRevision:0,expectedSnapshotHash:null,payloadHash:a.payloadHash,next:e.next};return N(i),L(void 0,i),await this.writeState({...n,version:"1.2",runControl:e.next}),a}trySetFilePerms(e){try{q(e)}catch{}}trySetDirPerms(e){try{B(e)}catch{}}async prepareLockForSnapshot(e,s){if(!e)return null;if(e.ownerKind!=="client")return e;const n=K(e,s),o={...e,leaseState:n};if(n==="expired")return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_client_lock_recovered",reason:"lease_expired"},"expired client-owned yoloop lock lease recovered before snapshot echo"),null;if(!o.pid||o.pid<=0)return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_client_lock_recovered",reason:"invalid_pid"},"invalid PID in client-owned yoloop lock; recovered"),null;if(o.hostname&&o.hostname!==w())return{...o,ownerPidState:"unknown"};try{return process.kill(o.pid,0),{...o,ownerPidState:"live"}}catch{return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_client_lock_recovered",reason:"dead_pid"},"stale client-owned yoloop lock recovered before snapshot echo"),null}}async maybeRecoverOrphanLock(e){const s=await this.readLock();if(!s)return!0;if(K(s,e)==="expired")return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_orphan_lock_recovered",reason:"lease_expired"},"expired yoloop lock lease recovered"),!0;if(!s.pid||s.pid<=0)return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_orphan_lock_recovered",reason:"invalid_pid"},"invalid PID in lock file; recovered"),!0;if(s.hostname&&s.hostname!==w())return this.logger.warn({event:"yoloop_lock_remote_host",ownerKind:s.ownerKind??"server",ownerPidState:"unknown"},"lock held by a remote host; honoring"),!1;try{return process.kill(s.pid,0),!1}catch{return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_orphan_lock_recovered",reason:"dead_pid"},"orphan yoloop lock recovered"),!0}}async deleteLockBestEffort(){try{await u.unlink(this.lockPath)}catch{}}isSameClientSessionLock(e,s){return e.ownerKind==="client"&&e.sessionId===s.sessionId&&e.pid===process.pid&&e.hostname===w()}async cleanupOrphanTempFiles(){try{const e=$(this.statePath),s=await u.readdir(e),n=[`${g}.tmp.`,`${A}.tmp.`,`${P}.tmp.`],o=s.filter(r=>n.some(c=>r.startsWith(c)));for(const r of o)try{await u.unlink(S(e,r))}catch{}}catch{}}}function d(t){return t===null||typeof t!="object"?JSON.stringify(t):Array.isArray(t)?`[${t.map(d).join(",")}]`:`{${Object.entries(t).filter(([,e])=>e!==void 0).sort(([e],[s])=>e.localeCompare(s)).map(([e,s])=>`${JSON.stringify(e)}:${d(s)}`).join(",")}}`}function p(t,e){return V("sha256").update(`${t}\0${d(e)}`).digest("hex")}function Q(t){return p("p222-yoloop-continuation-replan-v1",t)}function k(t){if(!t||typeof t!="object"||Array.isArray(t))return!1;const{sessionId:e,...s}=t;return typeof e=="string"&&/^[a-f0-9]{64}$/.test(e)&&e===p("p205-yoloop-session-v1",s)}function ee(t){return p("p9390812898200217364-yoloop-session-v1",t)}function te(t){return p("p9390812898200217364-yoloop-rollover-v1",t)}function se(t){return p("p9390812898200217364-yoloop-successor-run-v1",t)}function ne(t){return p("p9046281030721413419-yoloop-successor-transition-v1",t)}function oe(t,e){return t.operationId===e.operationId&&t.idempotencyKey===e.idempotencyKey}function R(t){const e=/^[A-Za-z0-9._:-]{1,128}$/;Y(t.successor);const s={operationId:t.operationId,idempotencyKey:t.idempotencyKey,successor:t.successor,state:t.state,...t.acknowledgement?{acknowledgement:t.acknowledgement}:{},...t.reservation?{reservation:t.reservation}:{}},n=t.acknowledgement?.idempotencyKey,o=t.acknowledgement?.receipt.idempotencyKey,r=t.reservation?.authority.idempotencyKey;if(t.successor.type!=="start_successor_run"||!e.test(t.operationId)||!e.test(t.idempotencyKey)||t.operationId!==t.successor.operationId||t.idempotencyKey!==t.successor.idempotencyKey||!/^[a-f0-9]{64}$/.test(t.payloadHash)||t.payloadHash!==ne(s)||Buffer.byteLength(d(s),"utf8")>1048576||t.acknowledgement!==void 0&&(t.acknowledgement.type!=="acknowledge_continuation_authority"||typeof n!="string"||!e.test(n)||o!==n)||t.reservation!==void 0&&(t.reservation.type!=="reserve_continuation_authority"||typeof r!="string"||!e.test(r))||n!==void 0&&r===n)throw new Error("YOLOOP_SUCCESSOR_TRANSITION_CONFLICT")}function H(t,e){if(R(t),!(d(e.runControl)===d(t.successor.next))){const n=e.runControl;if(!n||n.runId!==t.successor.previousRunId||!["exhausted","success"].includes(n.status)||n.revision!==t.successor.expectedRevision||n.latestSnapshotHash!==t.successor.expectedSnapshotHash||e.currentSession!==null)throw new Error("YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT")}re(t,e)}function re(t,e){const s=e.pendingContinuationAuthorities??[],n=e.continuationReceipts??[],o=t.acknowledgement;if(o){const l=s.find(y=>y.idempotencyKey===o.idempotencyKey);if(!l||o.receipt.sessionId!==l.sessionId||d(o.receipt.storyIds)!==d(l.storyIds))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT");const h=s.filter(y=>y.idempotencyKey!==o.idempotencyKey),O=[...n.slice(-63),o.receipt];if(d(t.state.pendingContinuationAuthorities??[])!==d(h)||d(t.state.continuationReceipts??[])!==d(O))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT")}else if(d(t.state.pendingContinuationAuthorities??[])!==d(s)||d(t.state.continuationReceipts??[])!==d(n))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT");const r=t.reservation?.authority;if(!r)return;const c=String(r.idempotencyKey??""),a=t.state.pendingContinuationAuthorities??[],i=a.find(l=>l.idempotencyKey===c);if(i&&d(i)!==d(r))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT");if(!i&&a.length>=64)throw new Error("YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED")}function ie(t,e){H(t,e);const s=t.reservation?.authority,n=[...t.state.pendingContinuationAuthorities??[]];return s&&!n.some(o=>o.idempotencyKey===s.idempotencyKey)&&n.push(s),{...t.state,version:"1.2",runControl:t.successor.next,...s||t.state.pendingContinuationAuthorities!==void 0?{pendingContinuationAuthorities:n}:{}}}function v(t,e){const s=t.reservation?.authority,n=[...t.state.pendingContinuationAuthorities??[]];s&&!n.some(r=>r.idempotencyKey===s.idempotencyKey)&&n.push(s);const o={...t.state,version:"1.2",runControl:t.successor.next,...s||t.state.pendingContinuationAuthorities!==void 0?{pendingContinuationAuthorities:n}:{}};return d(e)===d(o)}function ae(t,e){return t.operationId===e.operationId&&t.idempotencyKey===e.idempotencyKey}function E(t){const e=/^[A-Za-z0-9._:-]{1,128}$/,s={runId:t.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.expectedRevision,expectedSnapshotHash:t.expectedSnapshotHash,expectedSessionId:t.expectedSessionId,expectedSessionFingerprint:t.expectedSessionFingerprint,expectedLeaseId:t.expectedLeaseId,coolDownUntil:t.coolDownUntil,reason:t.reason,exhaustedRun:t.exhaustedRun,next:t.next};if(!e.test(t.runId)||!e.test(t.operationId)||!e.test(t.idempotencyKey)||!/^[a-f0-9]{64}$/.test(t.expectedSessionFingerprint)||!/^[a-f0-9]{64}$/.test(t.payloadHash)||!Number.isSafeInteger(t.expectedRevision)||t.expectedRevision<0||!Number.isFinite(Date.parse(t.coolDownUntil))||!["YOLOOP_PER_RUN_CAP_REACHED","YOLOOP_SESSION_TIMEOUT"].includes(t.reason)||t.payloadHash!==te(s)||t.receipt.schemaVersion!=="YoloopSessionRolloverReceipt/1.0"||t.receipt.runId!==t.runId||t.receipt.operationId!==t.operationId||t.receipt.idempotencyKey!==t.idempotencyKey||t.receipt.payloadHash!==t.payloadHash||t.receipt.coolDownUntil!==t.coolDownUntil||t.receipt.revision!==(t.exhaustedRun?.revision??0)||t.receipt.snapshotHash!==(t.exhaustedRun?.latestSnapshotHash??null)||t.receipt.status!=="exhausted"||t.receipt.publicSafe!==!0)throw new Error("YOLOOP_SESSION_ROLLOVER_CONFLICT");if(t.expectedSessionId===null!=(t.expectedLeaseId===null)||t.expectedSessionId!==null&&!e.test(t.expectedSessionId)||t.expectedLeaseId!==null&&!e.test(t.expectedLeaseId))throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT")}function Y(t){const e=/^[A-Za-z0-9._:-]{1,128}$/,s={runId:t.runId,previousRunId:t.previousRunId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.expectedRevision,expectedSnapshotHash:t.expectedSnapshotHash,next:t.next};if(!e.test(t.runId)||!e.test(t.previousRunId)||t.runId===t.previousRunId||!e.test(t.operationId)||!e.test(t.idempotencyKey)||!/^[a-f0-9]{64}$/.test(t.payloadHash)||!Number.isSafeInteger(t.expectedRevision)||t.expectedRevision<1||t.payloadHash!==se(s))throw new Error("YOLOOP_RUN_SUCCESSOR_CONFLICT")}function U(t,e,s){E(t);const n=e.currentSession;if(!n||ee(n)!==t.expectedSessionFingerprint)throw new Error("YOLOOP_SESSION_ROLLOVER_SESSION_CONFLICT");const o=e.runControl;if((o?.revision??0)!==t.expectedRevision||(o?.latestSnapshotHash??null)!==t.expectedSnapshotHash||o&&o.runId!==t.runId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if(t.expectedSessionId===null||t.expectedLeaseId===null){if(s)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT")}else if(!s||s.sessionId!==t.expectedSessionId||s.leaseId!==t.expectedLeaseId)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT");const r=t.exhaustedRun;if(o)if(o.status==="exhausted"){if(d(r)!==d(o))throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT")}else{if(!r||r.status!=="exhausted")throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT");const a=r.receipts.at(-1);if(!a)throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT");const i={type:"transition_run",runId:o.runId,operationId:a.operationId,idempotencyKey:a.idempotencyKey,expectedRevision:o.revision,expectedSnapshotHash:o.latestSnapshotHash,payloadHash:a.payloadHash,next:r};N(i),L(o,i)}else{const a=r?.receipts[0],i=r?.receipts[1];if(r&&(r.runId!==t.runId||r.status!=="exhausted"||r.revision!==2||r.events.length!==2||r.progressSnapshots.length!==4||r.receipts.length!==2||a?.runId!==t.runId||a.revision!==1||a.status!=="created"||i?.runId!==t.runId||i.revision!==2||i.status!=="exhausted"||i.operationId!==`${t.operationId}:run`||i.idempotencyKey!==`${t.idempotencyKey}:run`||r.latestSnapshotHash!==i.snapshotHash||r.predecessor!==void 0))throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT")}const c={...e,version:"1.2",coolDownUntil:t.coolDownUntil,currentSession:null,...r?{runControl:r}:{}};if(d(c)!==d(t.next))throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT")}function D(t){const{receipts:e,...s}=t.next;return p("p220-yoloop-run-transition-payload-v1",{runId:t.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.expectedRevision,expectedSnapshotHash:t.expectedSnapshotHash,next:s})}const T=new Set(["success","partial","blocked","failure","exhausted","cancelled"]),ce={created:["running","cancelling","exhausted"],running:["pausing","cancelling","success","partial","blocked","failure","exhausted"],pausing:["paused","cancelling","failure","exhausted"],paused:["resuming","cancelling","exhausted"],resuming:["running","cancelling","failure","exhausted"],cancelling:["cancelled","failure","exhausted"]};function de(t,e){return e==="pause"?t==="running"?"pausing":null:e==="resume"?t==="paused"?"resuming":null:T.has(t)||t==="cancelling"?null:"cancelling"}function le(t){const e=t.current.revision+1,s=t.action==="cancel"?["YOLOOP_RUN_CANCEL_REQUESTED"]:[],n={schemaVersion:"YoloopOperationalBudgetSummary/1.0",limits:t.current.operationalBudget.limits,usage:t.current.usage,exhaustedDimension:null},o={schemaVersion:"ProgressSnapshot/1.0",runId:t.current.runId,sequence:t.current.progressSnapshots.length+1,revision:e,operationId:t.operationId,boundary:t.action,phase:"before",status:t.current.status,occurredAt:t.occurredAt,parentSnapshotHash:t.current.latestSnapshotHash,budget:n,reasonCodes:s,publicSafe:!0},r=p("p220-yoloop-progress-snapshot-id-v1",{runId:o.runId,operationId:o.operationId,sequence:o.sequence,phase:o.phase}),c={...o,snapshotId:r,snapshotHash:p("p220-yoloop-progress-snapshot-v1",{...o,snapshotId:r})},a={...o,sequence:c.sequence+1,phase:"after",status:t.target,parentSnapshotHash:c.snapshotHash},i=p("p220-yoloop-progress-snapshot-id-v1",{runId:a.runId,operationId:a.operationId,sequence:a.sequence,phase:a.phase}),l={...a,snapshotId:i,snapshotHash:p("p220-yoloop-progress-snapshot-v1",{...a,snapshotId:i})},h={schemaVersion:"YoloopRunEvent/1.0",runId:t.current.runId,operationId:t.operationId,kind:"intent",sequence:t.current.events.length+1,revision:e,occurredAt:t.occurredAt,fromStatus:t.current.status,toStatus:t.target,reasonCodes:s,budgetDimension:null,parentEventHash:t.current.events.at(-1)?.eventHash??null,publicSafe:!0},O=p("p220-yoloop-run-event-id-v1",{runId:h.runId,operationId:h.operationId,sequence:h.sequence}),y={...h,eventId:O,eventHash:p("p220-yoloop-run-event-v1",{...h,eventId:O})},I={...t.current,revision:e,status:t.target,events:[...t.current.events,y],progressSnapshots:[...t.current.progressSnapshots,c,l],latestSnapshotHash:l.snapshotHash,receipts:[...t.current.receipts]},_={runId:t.current.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.current.revision,expectedSnapshotHash:t.current.latestSnapshotHash,next:I},C=D(_),b={schemaVersion:"YoloopRunTransitionReceipt/1.0",runId:t.current.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,payloadHash:C,revision:e,snapshotHash:l.snapshotHash,status:t.target};return{type:"transition_run",..._,payloadHash:C,next:{...I,receipts:[...I.receipts,b]}}}function N(t){const e=/^[A-Za-z0-9._:-]{1,128}$/;if(!e.test(t.runId)||!e.test(t.operationId)||!e.test(t.idempotencyKey)||!/^[a-f0-9]{64}$/.test(t.payloadHash)||!Number.isSafeInteger(t.expectedRevision)||t.expectedRevision<0)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT");if(t.payloadHash!==D(t))throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}function L(t,e){const s=e.next,n=t?.events??[],o=t?.progressSnapshots??[],r=t?.receipts??[];if(s.schemaVersion!=="YoloopRunControl/1.0"||s.runId!==e.runId||s.revision!==e.expectedRevision+1||s.events.length!==n.length+1||s.progressSnapshots.length!==o.length+2||s.receipts.length!==r.length+1||d(s.events.slice(0,n.length))!==d(n)||d(s.progressSnapshots.slice(0,o.length))!==d(o)||d(s.receipts.slice(0,r.length))!==d(r))throw new Error("YOLOOP_RUN_TRANSITION_INVALID");if(t){if(T.has(t.status))throw new Error("YOLOOP_RUN_TERMINAL");const h=s.progressSnapshots.at(-2)?.boundary,O=t.status===s.status&&(h==="budget"||h==="retry");if(!(ce[t.status]??[]).includes(s.status)&&!O)throw new Error("YOLOOP_RUN_TRANSITION_INVALID");if(t.startedAt!==s.startedAt||t.deadlineAt!==s.deadlineAt)throw new Error("YOLOOP_RUN_TRANSITION_INVALID");if(d(t.predecessor)!==d(s.predecessor))throw new Error("YOLOOP_RUN_TRANSITION_INVALID")}else if(s.status!=="created")throw new Error("YOLOOP_RUN_TRANSITION_INVALID");const c=s.progressSnapshots.at(-2),a=s.progressSnapshots.at(-1),i=s.events.at(-1),l=s.receipts.at(-1);if(!c||!a||!i||!l||c.phase!=="before"||a.phase!=="after"||c.operationId!==e.operationId||a.operationId!==e.operationId||i.operationId!==e.operationId||c.runId!==e.runId||a.runId!==e.runId||i.runId!==e.runId||c.revision!==s.revision||a.revision!==s.revision||i.revision!==s.revision||c.parentSnapshotHash!==(t?.latestSnapshotHash??null)||a.parentSnapshotHash!==c.snapshotHash||s.latestSnapshotHash!==a.snapshotHash||l.schemaVersion!=="YoloopRunTransitionReceipt/1.0"||l.runId!==e.runId||l.operationId!==e.operationId||l.idempotencyKey!==e.idempotencyKey||l.payloadHash!==e.payloadHash||l.revision!==s.revision||l.snapshotHash!==s.latestSnapshotHash||l.status!==s.status||Date.parse(s.deadlineAt)<=Date.parse(s.startedAt))throw new Error("YOLOOP_RUN_TRANSITION_INVALID");F(c),F(a),ue(i,n.at(-1)?.eventHash??null)}function F(t){const{snapshotHash:e,...s}=t;if(e!==p("p220-yoloop-progress-snapshot-v1",s))throw new Error("YOLOOP_RUN_SNAPSHOT_CONFLICT")}function ue(t,e){const{eventHash:s,...n}=t;if(t.parentEventHash!==e||s!==p("p220-yoloop-run-event-v1",n))throw new Error("YOLOOP_RUN_SNAPSHOT_CONFLICT")}function he(t,e){return`lease-${pe(`${t}:${e}`)}`}function pe(t){let e=2166136261;for(let s=0;s<t.length;s+=1)e^=t.charCodeAt(s),e=Math.imul(e,16777619)>>>0;return e.toString(36).padStart(7,"0")}function K(t,e){const s=t.leaseExpiresAt??t.ttlExpiresAt,n=Date.parse(s),o=Date.parse(e);return!Number.isFinite(n)||!Number.isFinite(o)?"unknown":n<=o?"expired":"active"}function Oe(t,e){const s=f(e);return{...t,version:t.version==="1.2"?"1.2":"1.1",lastResetAt:t.lastResetAt??s.lastResetAt,storiesRunToday:t.storiesRunToday??0,iterationsToday:t.iterationsToday??0,iterationsTodayDate:t.iterationsTodayDate??s.iterationsTodayDate,tokensUsedToday:t.tokensUsedToday??0,lastRunAt:t.lastRunAt??null,coolDownUntil:t.coolDownUntil??null,totalRunsAllTime:t.totalRunsAllTime??0,totalStoriesAllTime:t.totalStoriesAllTime??0,currentSession:t.currentSession??null,...t.runControl!==void 0?{runControl:t.runControl}:{},...t.continuationPolicy!==void 0?{continuationPolicy:t.continuationPolicy}:{},...t.continuationSession!==void 0?{continuationSession:t.continuationSession}:{},...t.pendingContinuationAuthorities!==void 0?{pendingContinuationAuthorities:t.pendingContinuationAuthorities}:{},...t.continuationReceipts!==void 0?{continuationReceipts:t.continuationReceipts}:{},...t.continuationRecoveryReceipts!==void 0?{continuationRecoveryReceipts:t.continuationRecoveryReceipts}:{},...t.continuationReplanReceipts!==void 0?{continuationReplanReceipts:t.continuationReplanReceipts}:{},recovery_history:Array.isArray(t.recovery_history)?M(t.recovery_history):[]}}function M(t){return t.length<=m?t:t.slice(t.length-m)}export{x as YOLOOP_PERSISTENCE_CAPABILITIES,ve as YoloopClientStateStore,Q as buildYoloopContinuationReplanPayloadHash,D as buildYoloopRunTransitionPayloadHash,te as buildYoloopSessionRolloverPayloadHash,se as buildYoloopSuccessorRunPayloadHash,ne as buildYoloopSuccessorTransitionPayloadHash,M as capRecoveryHistory,f as createDefaultCounters,Re as createDefaultState,ee as fingerprintYoloopSession};