@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.
- package/dist/release-source.json +1 -1
- package/dist/sbom.cdx.json +1194 -1194
- package/install.ps1 +1 -1
- package/install.sh +1 -1
- package/package.json +2 -2
- package/packages/client/dist/adapters/adapter-registry.js +1 -108
- package/packages/client/dist/adapters/antigravity-adapter.js +2 -77
- package/packages/client/dist/adapters/claude-code-adapter.js +3 -79
- package/packages/client/dist/adapters/codex-adapter.js +2 -80
- package/packages/client/dist/adapters/cursor-adapter.js +4 -115
- package/packages/client/dist/adapters/gemini-adapter.js +2 -71
- package/packages/client/dist/adapters/grok-adapter.js +2 -42
- package/packages/client/dist/adapters/index.js +1 -22
- package/packages/client/dist/adapters/platform-detector.js +1 -212
- package/packages/client/dist/adapters/target-adapter.js +0 -12
- package/packages/client/dist/adapters/vscode-adapter.js +2 -72
- package/packages/client/dist/agent/refresh-stubs.js +2 -417
- package/packages/client/dist/agent/update-agent-yaml.js +1 -102
- package/packages/client/dist/agent/update-description.js +1 -251
- package/packages/client/dist/cache/crypto-utils.js +1 -78
- package/packages/client/dist/cache/encrypted-cache.js +1 -108
- package/packages/client/dist/cache/in-memory-asset-cache.js +1 -73
- package/packages/client/dist/cache/index.js +1 -13
- package/packages/client/dist/cache/protected-pi-boundary.js +1 -44
- package/packages/client/dist/checkpoint/checkpoint-client-reader.js +2 -127
- package/packages/client/dist/checkpoint/index.js +1 -11
- package/packages/client/dist/checkpoint/shared-checkpoint-types.js +1 -32
- package/packages/client/dist/cli.js +3 -290
- package/packages/client/dist/commands/activate.js +8 -394
- package/packages/client/dist/commands/architecture-answers.js +7 -274
- package/packages/client/dist/commands/architecture-memory-terminal.js +4 -253
- package/packages/client/dist/commands/architecture-non-ui-continuation.js +1 -100
- package/packages/client/dist/commands/architecture-root-binding.js +1 -51
- package/packages/client/dist/commands/cache-status.js +2 -112
- package/packages/client/dist/commands/config.js +1 -88
- package/packages/client/dist/commands/doctor.js +2 -635
- package/packages/client/dist/commands/invoke.d.ts +6 -0
- package/packages/client/dist/commands/invoke.js +142 -7140
- package/packages/client/dist/commands/legacy-epic-terminal.js +1 -71
- package/packages/client/dist/commands/legacy-story-branch.js +1 -96
- package/packages/client/dist/commands/legacy-story-pr.js +1 -276
- package/packages/client/dist/commands/mutation-qa-checkpoint.js +5 -285
- package/packages/client/dist/commands/mutation-qa-gate.js +1 -54
- package/packages/client/dist/commands/mutation-qa-git.js +1 -41
- package/packages/client/dist/commands/mutation-qa-policy.js +1 -102
- package/packages/client/dist/commands/mutation-qa-run.js +3 -219
- package/packages/client/dist/commands/mutation-qa-verdict.js +1 -149
- package/packages/client/dist/commands/mutation-qa.js +1 -220
- package/packages/client/dist/commands/native-agent.js +2 -136
- package/packages/client/dist/commands/planning-diagnosis.js +1 -43
- package/packages/client/dist/commands/planning-generation.js +1 -53
- package/packages/client/dist/commands/planning-memory.js +1 -40
- package/packages/client/dist/commands/planning-research.js +1 -42
- package/packages/client/dist/commands/private-session.js +1 -75
- package/packages/client/dist/commands/property-qa.js +1 -139
- package/packages/client/dist/commands/qa-control.js +1 -16
- package/packages/client/dist/commands/qa-scan-browser-launch.js +1 -55
- package/packages/client/dist/commands/qa-scan-browser-route.js +1 -28
- package/packages/client/dist/commands/qa-scan-capture.js +5 -663
- package/packages/client/dist/commands/qa-scan-discovery.js +3 -376
- package/packages/client/dist/commands/qa-scan-review.js +5 -826
- package/packages/client/dist/commands/qa-synthetic-backend-capture.js +1 -266
- package/packages/client/dist/commands/qa-synthetic-backend-review.js +1 -297
- package/packages/client/dist/commands/qa-synthetic-backend.js +2 -340
- package/packages/client/dist/commands/qa-synthetic-broker-port.js +1 -85
- package/packages/client/dist/commands/qa-synthetic-browser.js +1 -39
- package/packages/client/dist/commands/refactor-characterization.js +21 -671
- package/packages/client/dist/commands/refactor-commit.js +13 -399
- package/packages/client/dist/commands/refactor-control.js +3 -142
- package/packages/client/dist/commands/refactor-cpp-assertions.js +3 -93
- package/packages/client/dist/commands/refactor-review.js +5 -283
- package/packages/client/dist/commands/refactor-root.js +7 -588
- package/packages/client/dist/commands/refresh-memory.js +3 -156
- package/packages/client/dist/commands/shared-architecture-non-ui.js +1 -24
- package/packages/client/dist/commands/story-checkpoint.js +1 -171
- package/packages/client/dist/config/profile-directory.js +1 -13
- package/packages/client/dist/config/resolver-selection.js +1 -278
- package/packages/client/dist/config/secure-config.js +12 -281
- package/packages/client/dist/config.js +2 -223
- package/packages/client/dist/constants.js +1 -25
- package/packages/client/dist/context/context-collector.js +2 -222
- package/packages/client/dist/context/context-sanitizer.js +1 -145
- package/packages/client/dist/continuity/autonomous-governance.js +1 -285
- package/packages/client/dist/continuity/continuity-client-state-store.js +1 -1587
- package/packages/client/dist/continuity/invoke-hooks.js +1 -129
- package/packages/client/dist/continuity/migrations/001-initial-schema.js +2 -12
- package/packages/client/dist/continuity/migrations/002-processing-output-summary-indexes.js +2 -12
- package/packages/client/dist/continuity/migrations/003-headless-execution-attempts.js +2 -12
- package/packages/client/dist/continuity/migrations/004-outbox-delivery-state.js +2 -12
- package/packages/client/dist/continuity/migrations/005-bounded-headless-queue.js +2 -12
- package/packages/client/dist/continuity/migrations/006-native-agent-dispatches.js +2 -10
- package/packages/client/dist/continuity/native-agent-store.js +1 -69
- package/packages/client/dist/continuity/native-agent-types.js +1 -90
- package/packages/client/dist/continuity/outbox.js +1 -117
- package/packages/client/dist/continuity/runner-bootstrap-policy.js +1 -172
- package/packages/client/dist/continuity/sqlite-store.js +81 -2536
- package/packages/client/dist/effects/invoke-effect-gateways.js +1 -62
- package/packages/client/dist/errors/error-messages.js +2 -228
- package/packages/client/dist/errors/frontmatter-diagnostics.js +1 -125
- package/packages/client/dist/evidence/collector.js +1 -347
- package/packages/client/dist/evidence/index.js +1 -1
- package/packages/client/dist/evidence/types.js +0 -2
- package/packages/client/dist/fs/atomic-state-replace.js +1 -152
- package/packages/client/dist/generated/command-registry.generated.js +1 -4076
- package/packages/client/dist/generated/public-projections.generated.js +1 -149
- package/packages/client/dist/git/direct-yolo-c01-retry.js +4 -138
- package/packages/client/dist/git/direct-yolo-checkpoint.js +4 -1234
- package/packages/client/dist/git/git-lifecycle-step-ids.js +1 -14
- package/packages/client/dist/git/git-lifecycle-store.js +9 -1938
- package/packages/client/dist/git/legacy-story-branch-checkpoint.js +9 -259
- package/packages/client/dist/git/legacy-story-branch-setup.js +4 -348
- package/packages/client/dist/git/legacy-story-pr.js +70 -3538
- package/packages/client/dist/git/secure-filesystem-boundary.js +5 -182
- package/packages/client/dist/graph-retrieval/pre-command-hook.js +1 -284
- package/packages/client/dist/graph-retrieval/shared-graph-retrieval-contract.js +1 -237
- package/packages/client/dist/i18n/first-run.js +2 -60
- package/packages/client/dist/index.js +1 -47
- package/packages/client/dist/invocation/invoke-input.js +1 -24
- package/packages/client/dist/license/index.js +1 -5
- package/packages/client/dist/license/license-client.js +1 -304
- package/packages/client/dist/machine/fingerprint.js +2 -163
- package/packages/client/dist/machine/index.js +1 -5
- package/packages/client/dist/memory/project-memory-path-scope.js +1 -62
- package/packages/client/dist/memory/project-memory-writer.js +40 -1007
- package/packages/client/dist/memory/shared-project-memory-types.js +2 -253
- package/packages/client/dist/migrations/project-identity-capability.js +1 -50
- package/packages/client/dist/migrations/project-identity.js +7 -493
- package/packages/client/dist/migrations/state-json.js +4 -344
- package/packages/client/dist/policy/architecture-policy.js +2 -184
- package/packages/client/dist/policy/index.js +1 -7
- package/packages/client/dist/policy/shared-policy-types.js +0 -38
- package/packages/client/dist/policy/subagent-approval-policy.js +1 -55
- package/packages/client/dist/private-execution/clean-baseline.js +2 -279
- package/packages/client/dist/private-execution/codex-executor.js +4 -574
- package/packages/client/dist/private-execution/codex-host-runtime.js +1 -137
- package/packages/client/dist/private-execution/codex-lifecycle-guard.js +1 -97
- package/packages/client/dist/private-execution/codex-scoped-permissions.js +1 -128
- package/packages/client/dist/private-execution/delivery.js +13 -410
- package/packages/client/dist/private-execution/diagnosis-root-review.js +2 -294
- package/packages/client/dist/private-execution/frozen-handoff.js +6 -19
- package/packages/client/dist/private-execution/implementation-validation.js +2 -106
- package/packages/client/dist/private-execution/isolated-admission.js +23 -1432
- package/packages/client/dist/private-execution/isolated-session.js +5 -725
- package/packages/client/dist/private-execution/isolated-workspace.js +1 -122
- package/packages/client/dist/private-execution/pact-consumer-runtime.js +3 -409
- package/packages/client/dist/private-execution/pact-test-source.js +1 -258
- package/packages/client/dist/private-execution/pact-toolchain.js +3 -290
- package/packages/client/dist/private-execution/pact-write-set.js +1 -172
- package/packages/client/dist/private-execution/planning-diagnosis-checkpoint.js +5 -406
- package/packages/client/dist/private-execution/planning-generation-checkpoint.js +5 -462
- package/packages/client/dist/private-execution/planning-memory-checkpoint.js +3 -139
- package/packages/client/dist/private-execution/planning-research-checkpoint.js +4 -325
- package/packages/client/dist/private-execution/private-report-store.js +1 -42
- package/packages/client/dist/private-execution/property-test-candidate.js +1 -103
- package/packages/client/dist/private-execution/property-test-choices.js +3 -147
- package/packages/client/dist/private-execution/property-test-plan.js +7 -271
- package/packages/client/dist/private-execution/property-test-review.js +4 -258
- package/packages/client/dist/private-execution/property-test-runtime.js +9 -372
- package/packages/client/dist/private-execution/property-test-source.js +1 -139
- package/packages/client/dist/private-execution/root-checkpoint.js +1 -80
- package/packages/client/dist/private-execution/root-memory-git-evidence.js +3 -135
- package/packages/client/dist/private-execution/root-memory-recovery.js +1 -93
- package/packages/client/dist/private-execution/root-memory-request.js +1 -29
- package/packages/client/dist/private-execution/root-memory-scope.js +1 -178
- package/packages/client/dist/private-execution/root-memory-writer.js +1 -102
- package/packages/client/dist/private-execution/session-lease.js +1 -63
- package/packages/client/dist/private-execution/shared-private-admission-types.js +1 -50
- package/packages/client/dist/private-execution/shared-project-validation.js +1 -66
- package/packages/client/dist/private-execution/shared-root-memory-types.js +1 -61
- package/packages/client/dist/private-execution/ux-source-validation.js +1 -372
- package/packages/client/dist/private-execution/ux-write-set.js +2 -191
- package/packages/client/dist/resilience/circuit-breaker.js +1 -170
- package/packages/client/dist/resilience/degradation-manager.js +1 -164
- package/packages/client/dist/resilience/freshness-indicator.js +1 -100
- package/packages/client/dist/resilience/index.js +1 -8
- package/packages/client/dist/resilience/recovery-detector.js +1 -74
- package/packages/client/dist/resolvers/asset-resolver.js +0 -13
- package/packages/client/dist/resolvers/local-resolver.js +8 -255
- package/packages/client/dist/resolvers/remote-resolver.js +1 -233
- package/packages/client/dist/runner/binary.js +1 -404
- package/packages/client/dist/runner/cli.js +17 -1571
- package/packages/client/dist/runner/headless/adapters.js +1 -49
- package/packages/client/dist/runner/headless/claude-code-adapter.js +1 -79
- package/packages/client/dist/runner/headless/continuous-story-workspace.js +11 -1628
- package/packages/client/dist/runner/headless/epic-git-reconciliation-inventory.js +1 -69
- package/packages/client/dist/runner/headless/epic-merge-candidate-qa.js +2 -110
- package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.js +8 -1236
- package/packages/client/dist/runner/headless/epic-terminal-git-runner.js +2 -318
- package/packages/client/dist/runner/headless/fake-adapter.js +1 -62
- package/packages/client/dist/runner/headless/managed-profile.js +1 -132
- package/packages/client/dist/runner/headless/opencode-adapter.js +2 -145
- package/packages/client/dist/runner/headless/prepared-story-bundle.js +1 -43
- package/packages/client/dist/runner/headless/resolver.js +1 -231
- package/packages/client/dist/runner/headless/run-yolo.js +1 -885
- package/packages/client/dist/runner/headless/run-yoloop.js +2 -2574
- package/packages/client/dist/runner/headless/types.js +2 -574
- package/packages/client/dist/runner/local-console.js +13 -396
- package/packages/client/dist/runner/portal-sync-worker.js +1 -1487
- package/packages/client/dist/runner/project-identity.js +1 -106
- package/packages/client/dist/runner/scheduler.js +7 -995
- package/packages/client/dist/runner-cli.js +2 -18
- package/packages/client/dist/state/invocation-shape.js +1 -13
- package/packages/client/dist/state/project-state-snapshot.js +1 -1677
- package/packages/client/dist/state/state-json-repair.js +3 -785
- package/packages/client/dist/state/structural-dag-projection.js +1 -118
- package/packages/client/dist/telemetry/index.js +1 -5
- package/packages/client/dist/telemetry/offline-queue.js +1 -131
- package/packages/client/dist/tier/index.js +1 -5
- package/packages/client/dist/tier/tier-aware-client.js +1 -405
- package/packages/client/dist/types/index.js +1 -38
- package/packages/client/dist/version.js +1 -27
- package/packages/client/dist/yolo/change-evidence-collector.js +1 -126
- package/packages/client/dist/yolo/invoke-envelope-capability.js +1 -62
- package/packages/client/dist/yolo/spec-envelope-collector.js +5 -185
- package/packages/client/dist/yoloop/discovery-hook.js +2 -300
- package/packages/client/dist/yoloop/index.js +1 -8
- package/packages/client/dist/yoloop/invoke-hooks.js +12 -669
- package/packages/client/dist/yoloop/shared-discover-epics.js +1 -1007
- package/packages/client/dist/yoloop/shared-yoloop-types.js +1 -33
- package/packages/client/dist/yoloop/target-normalization.js +1 -180
- package/packages/client/dist/yoloop/yoloop-client-state-store.js +1 -1723
- package/targets-stubs/antigravity/gemini.md +1 -1
- package/targets-stubs/antigravity/skill/SKILL.md +1 -1
- package/targets-stubs/claude-code/neocortex-root.agent.yaml +1 -1
- package/targets-stubs/claude-code/neocortex-root.md +2 -2
- package/targets-stubs/claude-code/neocortex.agent.yaml +1 -1
- package/targets-stubs/claude-code/neocortex.md +2 -2
- package/targets-stubs/codex/AGENTS.md +1 -1
- package/targets-stubs/cursor/agent.md +2 -2
- package/targets-stubs/gemini-cli/agent.md +2 -2
- package/targets-stubs/opencode/neocortex-root.md +1 -1
- 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};
|