@agilno-tech/rivet 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +59 -0
  4. package/bin/cli.js +42 -0
  5. package/dist/agents/backend-django-agent/SKILL.md +35 -0
  6. package/dist/agents/backend-nestjs-agent/SKILL.md +36 -0
  7. package/dist/agents/boss-agent/SKILL.md +28 -0
  8. package/dist/agents/bounded-worker/SKILL.md +28 -0
  9. package/dist/agents/data-performance-agent/SKILL.md +32 -0
  10. package/dist/agents/delivery-ticketing-agent/SKILL.md +36 -0
  11. package/dist/agents/devops-agent/SKILL.md +34 -0
  12. package/dist/agents/engineering-manager/SKILL.md +28 -0
  13. package/dist/agents/frontend-nextjs-agent/SKILL.md +34 -0
  14. package/dist/agents/frontend-web-agent/SKILL.md +31 -0
  15. package/dist/agents/incident-response-agent/SKILL.md +42 -0
  16. package/dist/agents/jest-agent/SKILL.md +77 -0
  17. package/dist/agents/mobile-agent/SKILL.md +32 -0
  18. package/dist/agents/playwright-agent/SKILL.md +54 -0
  19. package/dist/agents/product-design-manager/SKILL.md +27 -0
  20. package/dist/agents/qa-agent/SKILL.md +29 -0
  21. package/dist/agents/quality-manager/SKILL.md +28 -0
  22. package/dist/agents/vitest-agent/SKILL.md +77 -0
  23. package/dist/governance/pre-push-rules.md +75 -0
  24. package/dist/governance/prompt-hygiene.md +22 -0
  25. package/dist/governance/review-checklist.md +24 -0
  26. package/dist/governance/safety-and-data.md +27 -0
  27. package/dist/governance/usage-rules.md +21 -0
  28. package/dist/mandatory/address-pr-feedback/SKILL.md +196 -0
  29. package/dist/mandatory/agentic-goal/SKILL.md +42 -0
  30. package/dist/mandatory/agentic-status/SKILL.md +74 -0
  31. package/dist/mandatory/apply-design/SKILL.md +228 -0
  32. package/dist/mandatory/check-ac/SKILL.md +124 -0
  33. package/dist/mandatory/create-pr-and-commit/SKILL.md +304 -0
  34. package/dist/mandatory/design/SKILL.md +312 -0
  35. package/dist/mandatory/feature-workflow/SKILL.md +87 -0
  36. package/dist/mandatory/hotfix/SKILL.md +195 -0
  37. package/dist/mandatory/pre-push/SKILL.md +272 -0
  38. package/dist/mandatory/project-context/SKILL.md +180 -0
  39. package/dist/mandatory/release-docs/SKILL.md +191 -0
  40. package/dist/mandatory/review-pr/SKILL.md +323 -0
  41. package/dist/mandatory/security-review/SKILL.md +157 -0
  42. package/dist/skills/api-contract/SKILL.md +77 -0
  43. package/dist/skills/backend-django/SKILL.md +27 -0
  44. package/dist/skills/backend-nestjs/SKILL.md +65 -0
  45. package/dist/skills/bug-ticket-creation/SKILL.md +43 -0
  46. package/dist/skills/cloudwatch-troubleshooting/SKILL.md +73 -0
  47. package/dist/skills/database-migration/SKILL.md +97 -0
  48. package/dist/skills/debugging/SKILL.md +32 -0
  49. package/dist/skills/devops-infra/SKILL.md +26 -0
  50. package/dist/skills/documentation/SKILL.md +24 -0
  51. package/dist/skills/frontend-nextjs/SKILL.md +27 -0
  52. package/dist/skills/incident-postmortem/SKILL.md +38 -0
  53. package/dist/skills/kubernetes-troubleshooting/SKILL.md +71 -0
  54. package/dist/skills/mobile-react-native/SKILL.md +37 -0
  55. package/dist/skills/postgres-analytics/SKILL.md +29 -0
  56. package/dist/skills/product-jira-ticketing/SKILL.md +28 -0
  57. package/dist/skills/qa-bug-analysis/SKILL.md +27 -0
  58. package/dist/skills/refactoring/SKILL.md +32 -0
  59. package/dist/skills/sprint-planning/SKILL.md +32 -0
  60. package/dist/skills/testing-quality/SKILL.md +62 -0
  61. package/dist/v2/protocols/agent-orchestration.md +49 -0
  62. package/dist/v2/protocols/delivery.md +45 -0
  63. package/dist/v2/protocols/design-authority.md +29 -0
  64. package/dist/v2/protocols/goal-graph.md +44 -0
  65. package/dist/v2/protocols/pattern-first-development.md +29 -0
  66. package/dist/v2/protocols/qa-evidence.md +29 -0
  67. package/dist/v2/protocols/security.md +29 -0
  68. package/dist/v2/schemas/event.schema.json +86 -0
  69. package/dist/v2/schemas/evidence.schema.json +94 -0
  70. package/dist/v2/schemas/feature-decomposition.schema.json +48 -0
  71. package/dist/v2/schemas/feature-plan.schema.json +95 -0
  72. package/dist/v2/schemas/goal-graph.schema.json +61 -0
  73. package/dist/v2/schemas/integration.schema.json +119 -0
  74. package/dist/v2/schemas/orchestration.schema.json +69 -0
  75. package/dist/v2/schemas/project.schema.json +209 -0
  76. package/dist/v2/schemas/providers.schema.json +142 -0
  77. package/dist/v2/schemas/quality.schema.json +50 -0
  78. package/dist/v2/schemas/work-action.schema.json +44 -0
  79. package/dist/v2/schemas/work-request.schema.json +253 -0
  80. package/dist/v2/templates/evidence/qa-bundle.json +194 -0
  81. package/dist/v2/templates/github-actions/rivet-deploy.yml +98 -0
  82. package/dist/v2/templates/harness/SKILL.md +90 -0
  83. package/dist/v2/templates/project/.rivet/orchestration.yaml +42 -0
  84. package/dist/v2/templates/project/.rivet/project.yaml +14 -0
  85. package/dist/v2/templates/project/.rivet/providers.yaml +8 -0
  86. package/dist/v2/templates/project/.rivet/quality.yaml +16 -0
  87. package/package.json +58 -0
  88. package/protocols/agent-orchestration.md +49 -0
  89. package/protocols/delivery.md +45 -0
  90. package/protocols/design-authority.md +29 -0
  91. package/protocols/goal-graph.md +44 -0
  92. package/protocols/pattern-first-development.md +29 -0
  93. package/protocols/qa-evidence.md +29 -0
  94. package/protocols/security.md +29 -0
  95. package/schemas/event.schema.json +86 -0
  96. package/schemas/evidence.schema.json +94 -0
  97. package/schemas/feature-decomposition.schema.json +48 -0
  98. package/schemas/feature-plan.schema.json +95 -0
  99. package/schemas/goal-graph.schema.json +61 -0
  100. package/schemas/integration.schema.json +119 -0
  101. package/schemas/orchestration.schema.json +69 -0
  102. package/schemas/project.schema.json +209 -0
  103. package/schemas/providers.schema.json +142 -0
  104. package/schemas/quality.schema.json +50 -0
  105. package/schemas/work-action.schema.json +44 -0
  106. package/schemas/work-request.schema.json +253 -0
  107. package/src/adapters/confluence.js +138 -0
  108. package/src/adapters/contract.js +657 -0
  109. package/src/adapters/factory.js +122 -0
  110. package/src/adapters/figma.js +178 -0
  111. package/src/adapters/fixtures.js +183 -0
  112. package/src/adapters/github.js +314 -0
  113. package/src/adapters/http.js +704 -0
  114. package/src/adapters/jira.js +195 -0
  115. package/src/adapters/linear.js +171 -0
  116. package/src/adapters/node-transport.js +59 -0
  117. package/src/cli/integration-setup-prompt.js +32 -0
  118. package/src/cli/interrupt.js +25 -0
  119. package/src/cli/main.js +596 -0
  120. package/src/cli/output.js +174 -0
  121. package/src/cli/parse-args.js +246 -0
  122. package/src/cli/project-discovery.js +70 -0
  123. package/src/cli/task-confirmation.js +38 -0
  124. package/src/cli/task-presentation.js +22 -0
  125. package/src/clients/claude.js +214 -0
  126. package/src/clients/codex.js +180 -0
  127. package/src/clients/compatibility.js +23 -0
  128. package/src/clients/contract.js +224 -0
  129. package/src/clients/fake.js +170 -0
  130. package/src/clients/process-runner.js +657 -0
  131. package/src/clients/result-contract.js +78 -0
  132. package/src/commands/delivery-publish.js +80 -0
  133. package/src/commands/delivery-remote.js +230 -0
  134. package/src/commands/delivery-review-update.js +39 -0
  135. package/src/commands/delivery-tracker.js +94 -0
  136. package/src/commands/delivery-transition.js +79 -0
  137. package/src/commands/delivery.js +223 -0
  138. package/src/commands/dependency-approval.js +21 -0
  139. package/src/commands/doctor.js +191 -0
  140. package/src/commands/evidence.js +44 -0
  141. package/src/commands/feature.js +212 -0
  142. package/src/commands/goals.js +231 -0
  143. package/src/commands/human-run.js +208 -0
  144. package/src/commands/human-task.js +278 -0
  145. package/src/commands/init.js +848 -0
  146. package/src/commands/install.js +814 -0
  147. package/src/commands/integration-setup.js +65 -0
  148. package/src/commands/integrations.js +54 -0
  149. package/src/commands/models.js +158 -0
  150. package/src/commands/orchestrate.js +61 -0
  151. package/src/commands/preflight.js +125 -0
  152. package/src/commands/protocols.js +228 -0
  153. package/src/commands/repositories.js +77 -0
  154. package/src/commands/setup-remote.js +41 -0
  155. package/src/commands/setup.js +214 -0
  156. package/src/commands/status.js +224 -0
  157. package/src/commands/support.js +44 -0
  158. package/src/commands/task-completion.js +104 -0
  159. package/src/commands/uninstall.js +234 -0
  160. package/src/commands/verify.js +171 -0
  161. package/src/commands/work.js +220 -0
  162. package/src/commands/worktrees.js +52 -0
  163. package/src/config/command-readiness.js +218 -0
  164. package/src/config/commands.js +206 -0
  165. package/src/config/defaults.js +55 -0
  166. package/src/config/load.js +230 -0
  167. package/src/config/validate.js +560 -0
  168. package/src/delivery/bitbucket-review.js +123 -0
  169. package/src/delivery/branch-publication.js +23 -0
  170. package/src/delivery/contract.js +379 -0
  171. package/src/delivery/github-deployment.js +132 -0
  172. package/src/delivery/github-review.js +5 -0
  173. package/src/delivery/github.js +382 -0
  174. package/src/delivery/gitlab-review.js +5 -0
  175. package/src/delivery/gitlab.js +395 -0
  176. package/src/delivery/prepare.js +90 -0
  177. package/src/delivery/publication-process.js +33 -0
  178. package/src/delivery/publication-transport.js +112 -0
  179. package/src/delivery/review-request.js +184 -0
  180. package/src/delivery/review-update.js +74 -0
  181. package/src/delivery/service.js +400 -0
  182. package/src/delivery/store.js +76 -0
  183. package/src/delivery/tracker-target.js +51 -0
  184. package/src/delivery/tracker-transition.js +158 -0
  185. package/src/delivery/tracker.js +164 -0
  186. package/src/discovery/git.js +85 -0
  187. package/src/discovery/portable.js +108 -0
  188. package/src/discovery/project.js +345 -0
  189. package/src/discovery/tools.js +145 -0
  190. package/src/evaluations/approval.js +20 -0
  191. package/src/evaluations/cost-policy.js +20 -0
  192. package/src/evaluations/harness-attempt.js +113 -0
  193. package/src/evaluations/live-runner.js +55 -0
  194. package/src/evaluations/profile.js +28 -0
  195. package/src/evaluations/report.js +11 -0
  196. package/src/evaluations/text-attempt.js +18 -0
  197. package/src/evidence/checksum.js +87 -0
  198. package/src/evidence/collect.js +727 -0
  199. package/src/evidence/validate.js +354 -0
  200. package/src/feature/accepted-integration.js +46 -0
  201. package/src/feature/actions.js +37 -0
  202. package/src/feature/client-profile.js +56 -0
  203. package/src/feature/decomposition-contract.js +58 -0
  204. package/src/feature/host-execution.js +633 -0
  205. package/src/feature/host-lock-recovery.js +96 -0
  206. package/src/feature/host-run-lock.js +32 -0
  207. package/src/feature/local-approval.js +183 -0
  208. package/src/feature/plan-contract.js +215 -0
  209. package/src/feature/planner.js +253 -0
  210. package/src/feature/run-store.js +241 -0
  211. package/src/feature/runtime-bridge.js +840 -0
  212. package/src/feature/verification-report.js +168 -0
  213. package/src/feature/workflow.js +375 -0
  214. package/src/git/client.js +509 -0
  215. package/src/git/integration-worktree.js +153 -0
  216. package/src/git/reconcile.js +222 -0
  217. package/src/git/reservations.js +450 -0
  218. package/src/git/worktrees.js +478 -0
  219. package/src/graph/completion.js +189 -0
  220. package/src/graph/fixtures.js +116 -0
  221. package/src/graph/reducer.js +124 -0
  222. package/src/graph/scheduler.js +252 -0
  223. package/src/graph/validate.js +106 -0
  224. package/src/install/managed.js +579 -0
  225. package/src/install/project-reference.js +33 -0
  226. package/src/install/project-runtime.js +142 -0
  227. package/src/install/runtime-integrity.cjs +141 -0
  228. package/src/integrations/capabilities.js +69 -0
  229. package/src/integrations/host-observation.js +26 -0
  230. package/src/integrations/registry.js +56 -0
  231. package/src/models/delegate.js +102 -0
  232. package/src/models/profiles.js +53 -0
  233. package/src/models/protocols.js +208 -0
  234. package/src/models/registry.js +83 -0
  235. package/src/models/transport.js +59 -0
  236. package/src/policy/approvals.js +251 -0
  237. package/src/policy/authority.js +282 -0
  238. package/src/policy/budget.js +149 -0
  239. package/src/policy/command-bootstrap.js +219 -0
  240. package/src/policy/commands.js +803 -0
  241. package/src/prompts/launch-contract.js +43 -0
  242. package/src/prompts/planning-contract.js +92 -0
  243. package/src/protocols/presentation.js +53 -0
  244. package/src/protocols/project.js +289 -0
  245. package/src/quality/runner.js +497 -0
  246. package/src/quality/traceability.js +215 -0
  247. package/src/repositories/identity.js +59 -0
  248. package/src/repositories/index.js +2 -0
  249. package/src/repositories/provider.js +210 -0
  250. package/src/runtime/application.js +353 -0
  251. package/src/runtime/dependency-directory.js +33 -0
  252. package/src/runtime/harness-discovery.js +121 -0
  253. package/src/runtime/heartbeat.js +47 -0
  254. package/src/runtime/instance-store.js +112 -0
  255. package/src/runtime/orchestrator.js +1133 -0
  256. package/src/runtime/portable-dependencies.js +94 -0
  257. package/src/runtime/recovery.js +190 -0
  258. package/src/runtime/retry.js +54 -0
  259. package/src/runtime/supervisor.js +166 -0
  260. package/src/runtime/worktree-bootstrap.js +169 -0
  261. package/src/state/event-store.js +333 -0
  262. package/src/state/lock.js +266 -0
  263. package/src/state/paths.js +274 -0
  264. package/src/state/redact.js +106 -0
  265. package/src/state/snapshot-store.js +310 -0
  266. package/src/status/public/app.js +96 -0
  267. package/src/status/public/index.html +37 -0
  268. package/src/status/public/styles.css +50 -0
  269. package/src/status/server.js +323 -0
  270. package/src/status/view-model.js +356 -0
  271. package/src/support/bundle.js +195 -0
  272. package/src/support/failure-report.js +96 -0
  273. package/src/work-request/contract.js +250 -0
  274. package/src/work-request/host.js +78 -0
  275. package/src/work-request/local.js +163 -0
  276. package/src/work-request/tracker.js +105 -0
  277. package/templates/evidence/qa-bundle.json +194 -0
  278. package/templates/github-actions/rivet-deploy.yml +98 -0
  279. package/templates/harness/SKILL.md +90 -0
  280. package/templates/project/.rivet/orchestration.yaml +42 -0
  281. package/templates/project/.rivet/project.yaml +14 -0
  282. package/templates/project/.rivet/providers.yaml +8 -0
  283. package/templates/project/.rivet/quality.yaml +16 -0
@@ -0,0 +1,222 @@
1
+ import { isAbsolute, join, resolve } from 'node:path';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+
4
+ import { assertGitClient } from './client.js';
5
+ import { WorktreeError, verifyReservedWorktree } from './worktrees.js';
6
+ import { assertResolvedStatePaths } from '../state/paths.js';
7
+ import { acquireLock, LockBusyError } from '../state/lock.js';
8
+
9
+ const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
10
+ const LEASE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
11
+ const BRANCH = /^(?![./])(?!.*(?:\.\.|\/\/|@\{|\\|[~^:?*\[\]\u0000-\u0020\u007f]))(?!.*\/$)(?!.*\.lock(?:\/|$))[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
12
+
13
+ export class ReconcileError extends Error {
14
+ constructor(reason = 'invalid-input') {
15
+ const messages = {
16
+ 'invalid-input': 'Worktree reconciliation request is invalid.',
17
+ 'lease-changed': 'Worktree lease or topology changed during reconciliation.',
18
+ 'integration-failed': 'Fast-forward integration failed safely.',
19
+ 'integration-outcome-unknown': 'Fast-forward integration outcome requires explicit inspection.',
20
+ };
21
+ super(messages[reason] ?? messages['invalid-input']);
22
+ this.name = 'ReconcileError';
23
+ this.code = `ERR_RECONCILE_${reason.replaceAll('-', '_').toUpperCase()}`;
24
+ this.safeMessage = this.message;
25
+ this.details = Object.freeze({ reason });
26
+ }
27
+ }
28
+
29
+ function fail(reason) { throw new ReconcileError(reason); }
30
+
31
+ function capture(value, allowed, required) {
32
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail('invalid-input');
33
+ let keys;
34
+ try { keys = Reflect.ownKeys(value); } catch { fail('invalid-input'); }
35
+ if (keys.some(key => typeof key !== 'string' || !allowed.has(key))) fail('invalid-input');
36
+ const result = Object.create(null);
37
+ try {
38
+ for (const key of keys) {
39
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
40
+ if (!descriptor?.enumerable) fail('invalid-input');
41
+ result[key] = value[key];
42
+ }
43
+ } catch (error) {
44
+ if (error instanceof ReconcileError) throw error;
45
+ fail('invalid-input');
46
+ }
47
+ if (required.some(key => !Object.hasOwn(result, key))) fail('invalid-input');
48
+ return result;
49
+ }
50
+
51
+ function validateInput(input) {
52
+ const value = capture(input, new Set([
53
+ 'projectRoot', 'statePaths', 'nodeId', 'ownerId', 'leaseId', 'integrationBranch', 'integrate',
54
+ ]), ['projectRoot', 'statePaths', 'nodeId', 'ownerId', 'leaseId', 'integrationBranch', 'integrate']);
55
+ if (typeof value.projectRoot !== 'string' || !isAbsolute(value.projectRoot) || resolve(value.projectRoot) !== value.projectRoot
56
+ || value.projectRoot.length > 1_024 || !ID.test(value.nodeId) || !ID.test(value.ownerId)
57
+ || !LEASE_ID.test(value.leaseId) || !BRANCH.test(value.integrationBranch)
58
+ || value.integrationBranch.normalize('NFKC') !== value.integrationBranch || typeof value.integrate !== 'boolean') fail('invalid-input');
59
+ assertResolvedStatePaths(value.statePaths);
60
+ return Object.freeze(value);
61
+ }
62
+
63
+ function frozenPaths(paths) { return Object.freeze([...paths].sort()); }
64
+
65
+ function report(status, reason, values) {
66
+ const result = {
67
+ status,
68
+ ...(reason ? { reason } : {}),
69
+ nodeId: values.nodeId,
70
+ branch: values.branch,
71
+ integrationBranch: values.integrationBranch,
72
+ baseSha: values.baseSha,
73
+ workerTip: values.workerTip,
74
+ integrationTip: values.integrationTip,
75
+ cooperationBoundary: 'cooperating-orchestrator-actors',
76
+ evidence: Object.freeze({
77
+ changedPaths: frozenPaths(values.changedPaths ?? []),
78
+ uncommittedPaths: frozenPaths(values.uncommittedPaths ?? []),
79
+ missingIntendedPaths: frozenPaths(values.missingIntendedPaths ?? []),
80
+ unexpectedPaths: frozenPaths(values.unexpectedPaths ?? []),
81
+ conflictingPaths: frozenPaths(values.conflictingPaths ?? []),
82
+ }),
83
+ };
84
+ return Object.freeze(result);
85
+ }
86
+
87
+ function contains(scope, path) {
88
+ return scope.path === path || (scope.directory && path.startsWith(`${scope.path}/`));
89
+ }
90
+
91
+ function baseValues(value, verified) {
92
+ return {
93
+ nodeId: value.nodeId,
94
+ branch: verified.reservation.branch,
95
+ integrationBranch: value.integrationBranch,
96
+ baseSha: verified.reservation.baseSha,
97
+ workerTip: verified.worker.headSha,
98
+ integrationTip: verified.repository.headSha,
99
+ };
100
+ }
101
+
102
+ async function acquireIntegrationLock(path) {
103
+ const deadline = Date.now() + 2_000;
104
+ while (true) {
105
+ try { return await acquireLock(path); } catch (error) {
106
+ if (!(error instanceof LockBusyError) || Date.now() >= deadline) throw error;
107
+ await delay(5);
108
+ }
109
+ }
110
+ }
111
+
112
+ export async function reconcileWorktree(input, options = {}) {
113
+ const value = validateInput(input);
114
+ const capturedOptions = capture(options, new Set(['gitClient', 'nowMs']), ['gitClient']);
115
+ if (capturedOptions.nowMs !== undefined
116
+ && (!Number.isSafeInteger(capturedOptions.nowMs) || capturedOptions.nowMs < 0)) fail('invalid-input');
117
+ const client = capturedOptions.gitClient;
118
+ assertGitClient(client);
119
+ const verified = await verifyReservedWorktree({
120
+ projectRoot: value.projectRoot,
121
+ statePaths: value.statePaths,
122
+ nodeId: value.nodeId,
123
+ ownerId: value.ownerId,
124
+ leaseId: value.leaseId,
125
+ }, { gitClient: client, nowMs: capturedOptions.nowMs });
126
+ const values = baseValues(value, verified);
127
+
128
+ if (verified.repository.detached || verified.repository.branch !== value.integrationBranch) {
129
+ return report('blocked', 'integration-branch-mismatch', values);
130
+ }
131
+ const integrationEvidencePaths = await client.statusPaths(verified.repository.root);
132
+ if (integrationEvidencePaths.length > 0) {
133
+ return report('blocked', 'integration-uncommitted-changes', {
134
+ ...values,
135
+ uncommittedPaths: integrationEvidencePaths,
136
+ });
137
+ }
138
+ if (verified.worker.dirty) {
139
+ return report('blocked', 'worker-uncommitted-changes', {
140
+ ...values,
141
+ uncommittedPaths: verified.worker.dirtyPaths,
142
+ });
143
+ }
144
+ if (!(await client.isAncestor(verified.worker.root, values.baseSha, values.workerTip))) {
145
+ return report('blocked', 'worker-base-mismatch', values);
146
+ }
147
+ if (!(await client.isAncestor(verified.repository.root, values.baseSha, values.integrationTip))) {
148
+ return report('blocked', 'integration-base-mismatch', values);
149
+ }
150
+
151
+ const changedPaths = await client.changedPaths(verified.worker.root, values.baseSha, values.workerTip);
152
+ const missingIntendedPaths = verified.reservation.intendedPaths.filter(path => !changedPaths.includes(path));
153
+ const unexpectedPaths = changedPaths.filter(path => !verified.reservation.responsibilities.some(scope => contains(scope, path)));
154
+ if (missingIntendedPaths.length > 0 || unexpectedPaths.length > 0) {
155
+ return report('blocked', 'scope-mismatch', {
156
+ ...values, changedPaths, missingIntendedPaths, unexpectedPaths,
157
+ });
158
+ }
159
+
160
+ const fastForward = await client.isAncestor(verified.repository.root, values.integrationTip, values.workerTip);
161
+ if (!fastForward) {
162
+ const integrationPaths = await client.changedPaths(verified.repository.root, values.baseSha, values.integrationTip);
163
+ const conflictingPaths = changedPaths.filter(path => integrationPaths.includes(path));
164
+ return report('blocked', conflictingPaths.length > 0 ? 'integration-conflict' : 'non-fast-forward', {
165
+ ...values, changedPaths, conflictingPaths,
166
+ });
167
+ }
168
+
169
+ if (!value.integrate) return report('ready', null, { ...values, changedPaths });
170
+
171
+ const lock = await acquireIntegrationLock(join(value.statePaths.instanceDir, 'integration.lock'));
172
+ try {
173
+ // The shared integration lock makes this revalidation and mutation one cooperating transaction.
174
+ const current = await verifyReservedWorktree({
175
+ projectRoot: value.projectRoot,
176
+ statePaths: value.statePaths,
177
+ nodeId: value.nodeId,
178
+ ownerId: value.ownerId,
179
+ leaseId: value.leaseId,
180
+ }, { gitClient: client, nowMs: capturedOptions.nowMs });
181
+ if (current.stateVersion !== verified.stateVersion || current.reservation.repositoryId !== verified.reservation.repositoryId
182
+ || current.worker.headSha !== values.workerTip || current.repository.headSha !== values.integrationTip
183
+ || current.repository.branch !== value.integrationBranch || current.worker.dirty) {
184
+ fail('lease-changed');
185
+ }
186
+ const currentIntegrationEvidencePaths = await client.statusPaths(current.repository.root);
187
+ if (currentIntegrationEvidencePaths.length > 0) {
188
+ return report('blocked', 'integration-uncommitted-changes', {
189
+ ...values,
190
+ uncommittedPaths: currentIntegrationEvidencePaths,
191
+ });
192
+ }
193
+ try {
194
+ await client.fastForward(current.repository.root, {
195
+ branch: value.integrationBranch,
196
+ expectedTip: values.integrationTip,
197
+ newTip: values.workerTip,
198
+ });
199
+ } catch {
200
+ let observed;
201
+ try { observed = await client.inspectRepository(current.repository.root); }
202
+ catch { fail('integration-outcome-unknown'); }
203
+ if (observed.repositoryId !== current.repository.repositoryId
204
+ || observed.branch !== value.integrationBranch) fail('integration-outcome-unknown');
205
+ if (observed.headSha === values.workerTip && !observed.dirty) {
206
+ return report('integrated', null, { ...values, integrationTip: values.workerTip, changedPaths });
207
+ }
208
+ return report('blocked', 'integration-concurrent-change', {
209
+ ...values,
210
+ integrationTip: observed.headSha,
211
+ changedPaths,
212
+ uncommittedPaths: observed.dirtyPaths,
213
+ });
214
+ }
215
+ return report('integrated', null, { ...values, integrationTip: values.workerTip, changedPaths });
216
+ } catch (error) {
217
+ if (error instanceof ReconcileError || error instanceof WorktreeError) throw error;
218
+ fail('integration-failed');
219
+ } finally {
220
+ await lock.release();
221
+ }
222
+ }
@@ -0,0 +1,450 @@
1
+ import { constants } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { lstat, open, rename, unlink } from 'node:fs/promises';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+
7
+ import { acquireLock, LockBusyError } from '../state/lock.js';
8
+ import { assertResolvedStatePaths, verifyResolvedStatePaths } from '../state/paths.js';
9
+
10
+ const RESERVATION_FILE = 'worktree-reservations.json';
11
+ const MAX_BYTES = 1024 * 1024;
12
+ const MAX_RESERVATIONS = 1_024;
13
+ const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
14
+ const BRANCH = /^(?![./])(?!.*(?:\.\.|\/\/|@\{|\\|[~^:?*\[\]\u0000-\u0020\u007f]))(?!.*\/$)(?!.*\.lock(?:\/|$))[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
15
+ const REPOSITORY_ID = /^[a-z0-9][a-z0-9-]{0,127}$/;
16
+ const LEASE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
17
+ const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
18
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
19
+ const DIRECTORY = constants.O_DIRECTORY ?? 0;
20
+
21
+ export class ReservationError extends Error {
22
+ constructor(reason = 'invalid-reservation') {
23
+ const messages = {
24
+ 'invalid-reservation': 'Worktree reservation input is invalid.',
25
+ 'state-version-conflict': 'Worktree reservation state changed concurrently.',
26
+ 'lease-collision': 'Worktree reservation collides with an active lease.',
27
+ 'stale-lease': 'Expired worktree lease requires explicit reconciliation.',
28
+ 'lease-mismatch': 'Worktree lease identity does not match.',
29
+ 'unsafe-state': 'Worktree reservation state is unsafe.',
30
+ 'state-exhausted': 'Worktree reservation state version is exhausted.',
31
+ 'lease-not-expired': 'Worktree lease has not expired.',
32
+ };
33
+ super(messages[reason] ?? messages['invalid-reservation']);
34
+ this.name = 'ReservationError';
35
+ this.code = `ERR_WORKTREE_${reason.replaceAll('-', '_').toUpperCase()}`;
36
+ this.safeMessage = this.message;
37
+ this.details = Object.freeze({ reason });
38
+ }
39
+ }
40
+
41
+ function fail(reason) { throw new ReservationError(reason); }
42
+
43
+ function sameIdentity(left, right) {
44
+ return left.dev === right.dev && left.ino === right.ino;
45
+ }
46
+
47
+ function privateRegular(metadata) {
48
+ return metadata.isFile() && !metadata.isSymbolicLink() && metadata.nlink === 1 && (metadata.mode & 0o777) === 0o600;
49
+ }
50
+
51
+ function captureObject(value, allowed, required) {
52
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail('invalid-reservation');
53
+ let keys;
54
+ try { keys = Reflect.ownKeys(value); } catch { fail('invalid-reservation'); }
55
+ if (keys.some(key => typeof key !== 'string' || !allowed.has(key))) fail('invalid-reservation');
56
+ const result = Object.create(null);
57
+ try {
58
+ for (const key of keys) {
59
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
60
+ if (!descriptor?.enumerable) fail('invalid-reservation');
61
+ result[key] = value[key];
62
+ }
63
+ } catch (error) {
64
+ if (error instanceof ReservationError) throw error;
65
+ fail('invalid-reservation');
66
+ }
67
+ if (required.some(key => !Object.hasOwn(result, key))) fail('invalid-reservation');
68
+ return result;
69
+ }
70
+
71
+ function canonicalRelative(value, allowDirectory = true) {
72
+ if (typeof value !== 'string' || value.length === 0 || value.length > 500 || value.normalize('NFKC') !== value
73
+ || isAbsolute(value) || /[\u0000\r\n\\:]/.test(value) || value.includes('//')) fail('invalid-reservation');
74
+ const directory = allowDirectory && value.endsWith('/');
75
+ const normalized = value.replace(/\/$/, '');
76
+ const parts = normalized.split('/');
77
+ if (parts.length === 0 || parts.some(part => (
78
+ !part || part === '.' || part === '..' || part.endsWith('.') || part.endsWith(' ')
79
+ || /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(part)
80
+ || part.toUpperCase().toLowerCase().normalize('NFKC') === '.git'
81
+ ))) fail('invalid-reservation');
82
+ return Object.freeze({ path: normalized, directory });
83
+ }
84
+
85
+ function capturePathList(value, limit, allowDirectory) {
86
+ if (!Array.isArray(value)) fail('invalid-reservation');
87
+ const result = [];
88
+ try {
89
+ const length = value.length;
90
+ if (!Number.isSafeInteger(length) || length === 0 || length > limit) fail('invalid-reservation');
91
+ for (let index = 0; index < length; index += 1) {
92
+ if (!Object.hasOwn(value, index)) fail('invalid-reservation');
93
+ result.push(canonicalRelative(value[index], allowDirectory));
94
+ }
95
+ } catch (error) {
96
+ if (error instanceof ReservationError) throw error;
97
+ fail('invalid-reservation');
98
+ }
99
+ const keys = result.map(item => `${item.path.toLocaleLowerCase('en-US')}${item.directory ? '/' : ''}`);
100
+ if (new Set(keys).size !== keys.length) fail('invalid-reservation');
101
+ return Object.freeze(result);
102
+ }
103
+
104
+ function pathContains(scope, path) {
105
+ return scope.path === path || (scope.directory && path.startsWith(`${scope.path}/`));
106
+ }
107
+
108
+ function responsibilitiesOverlap(left, right) {
109
+ const foldedContains = (scope, path) => {
110
+ const leftPath = scope.path.toLocaleLowerCase('en-US');
111
+ const rightPath = path.toLocaleLowerCase('en-US');
112
+ return leftPath === rightPath || (scope.directory && rightPath.startsWith(`${leftPath}/`));
113
+ };
114
+ return left.some(a => right.some(b => foldedContains(a, b.path) || foldedContains(b, a.path)));
115
+ }
116
+
117
+ function cloneReservation(record) {
118
+ return Object.freeze({
119
+ ...record,
120
+ responsibilities: Object.freeze(record.responsibilities.map(item => Object.freeze({ ...item }))),
121
+ intendedPaths: Object.freeze([...record.intendedPaths]),
122
+ });
123
+ }
124
+
125
+ function captureReservation(input, options = {}) {
126
+ const source = captureObject(input, new Set([
127
+ 'projectRoot', 'statePaths', 'nodeId', 'branch', 'worktreePath', 'ownerId', 'baseSha',
128
+ 'responsibilities', 'intendedPaths', 'expiresAt', 'repositoryId',
129
+ ]), [
130
+ 'nodeId', 'branch', 'worktreePath', 'ownerId', 'baseSha', 'responsibilities',
131
+ 'intendedPaths', 'expiresAt', 'repositoryId',
132
+ ]);
133
+ const nowMs = options.nowMs ?? Date.now();
134
+ if (!Number.isSafeInteger(nowMs) || nowMs < 0 || !ID.test(source.nodeId) || source.nodeId.length > 64
135
+ || !ID.test(source.ownerId) || source.ownerId.length > 64 || !BRANCH.test(source.branch)
136
+ || source.branch.normalize('NFKC') !== source.branch || !REPOSITORY_ID.test(source.repositoryId)
137
+ || !SHA.test(source.baseSha) || typeof source.worktreePath !== 'string' || source.worktreePath.length > 1_024
138
+ || !isAbsolute(source.worktreePath) || resolve(source.worktreePath) !== source.worktreePath
139
+ || /[\u0000\r\n]/.test(source.worktreePath)) fail('invalid-reservation');
140
+ const expiryMs = Date.parse(source.expiresAt);
141
+ if (!Number.isSafeInteger(expiryMs) || expiryMs <= nowMs || expiryMs - nowMs > 90 * 24 * 60 * 60 * 1000
142
+ || new Date(expiryMs).toISOString() !== source.expiresAt) fail('invalid-reservation');
143
+ const responsibilities = capturePathList(source.responsibilities, 256, true);
144
+ const intended = capturePathList(source.intendedPaths, 256, false);
145
+ if (intended.some(path => path.directory || !responsibilities.some(scope => pathContains(scope, path.path)))) {
146
+ fail('invalid-reservation');
147
+ }
148
+ return Object.freeze({
149
+ schemaVersion: 1,
150
+ leaseId: randomUUID(),
151
+ nodeId: source.nodeId,
152
+ branch: source.branch,
153
+ worktreePath: source.worktreePath,
154
+ ownerId: source.ownerId,
155
+ baseSha: source.baseSha,
156
+ repositoryId: source.repositoryId,
157
+ responsibilities,
158
+ intendedPaths: Object.freeze(intended.map(item => item.path)),
159
+ expiresAt: source.expiresAt,
160
+ status: 'reserved',
161
+ cooperationBoundary: 'cooperating-orchestrator-actors',
162
+ });
163
+ }
164
+
165
+ function validatePersistedReservation(value) {
166
+ const expiryMs = Date.parse(value?.expiresAt);
167
+ if (!value || typeof value !== 'object' || Array.isArray(value)
168
+ || Reflect.ownKeys(value).some(key => typeof key !== 'string')
169
+ || Object.keys(value).length !== 13
170
+ || value.schemaVersion !== 1 || !LEASE_ID.test(value.leaseId) || !ID.test(value.nodeId)
171
+ || !BRANCH.test(value.branch) || typeof value.worktreePath !== 'string' || !isAbsolute(value.worktreePath)
172
+ || resolve(value.worktreePath) !== value.worktreePath || !ID.test(value.ownerId) || !SHA.test(value.baseSha)
173
+ || !REPOSITORY_ID.test(value.repositoryId) || !['reserved', 'active'].includes(value.status)
174
+ || value.cooperationBoundary !== 'cooperating-orchestrator-actors'
175
+ || !Array.isArray(value.responsibilities) || !Array.isArray(value.intendedPaths)
176
+ || !Number.isSafeInteger(expiryMs) || new Date(expiryMs).toISOString() !== value.expiresAt) fail('unsafe-state');
177
+ const responsibilities = value.responsibilities.map(item => {
178
+ if (!item || typeof item !== 'object' || Object.keys(item).length !== 2
179
+ || typeof item.path !== 'string' || typeof item.directory !== 'boolean') fail('unsafe-state');
180
+ const canonical = canonicalRelative(`${item.path}${item.directory ? '/' : ''}`, true);
181
+ if (canonical.path !== item.path || canonical.directory !== item.directory) fail('unsafe-state');
182
+ return canonical;
183
+ });
184
+ const intended = value.intendedPaths.map(path => canonicalRelative(path, false).path);
185
+ if (intended.some(path => !responsibilities.some(scope => pathContains(scope, path)))) fail('unsafe-state');
186
+ return cloneReservation({ ...value, responsibilities, intendedPaths: intended });
187
+ }
188
+
189
+ async function fileMetadata(path) {
190
+ try {
191
+ const metadata = await lstat(path);
192
+ if (!privateRegular(metadata) || metadata.size <= 0 || metadata.size > MAX_BYTES) fail('unsafe-state');
193
+ return metadata;
194
+ } catch (error) {
195
+ if (error.code === 'ENOENT') return null;
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ async function readState(path) {
201
+ const before = await fileMetadata(path);
202
+ if (!before) return Object.freeze({ schemaVersion: 1, version: 0, reservations: Object.freeze([]) });
203
+ let handle;
204
+ try {
205
+ handle = await open(path, constants.O_RDONLY | NOFOLLOW);
206
+ const opened = await handle.stat();
207
+ if (!privateRegular(opened) || !sameIdentity(before, opened)) fail('unsafe-state');
208
+ const buffer = Buffer.alloc(opened.size);
209
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
210
+ if (bytesRead !== buffer.length) fail('unsafe-state');
211
+ let source;
212
+ try { source = new TextDecoder('utf-8', { fatal: true }).decode(buffer); } catch { fail('unsafe-state'); }
213
+ const after = await lstat(path);
214
+ if (!privateRegular(after) || !sameIdentity(opened, after) || after.size !== opened.size || !source.endsWith('\n')) fail('unsafe-state');
215
+ let state;
216
+ try { state = JSON.parse(source); } catch { fail('unsafe-state'); }
217
+ if (!state || typeof state !== 'object' || Array.isArray(state) || Object.keys(state).length !== 3
218
+ || state.schemaVersion !== 1 || !Number.isSafeInteger(state.version) || state.version < 1
219
+ || !Array.isArray(state.reservations) || state.reservations.length > MAX_RESERVATIONS) fail('unsafe-state');
220
+ const reservations = state.reservations.map(validatePersistedReservation);
221
+ if (new Set(reservations.map(item => item.leaseId)).size !== reservations.length) fail('unsafe-state');
222
+ return Object.freeze({ schemaVersion: 1, version: state.version, reservations: Object.freeze(reservations) });
223
+ } finally {
224
+ await handle?.close();
225
+ }
226
+ }
227
+
228
+ async function openPrivateDirectory(path) {
229
+ const before = await lstat(path);
230
+ if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o700) fail('unsafe-state');
231
+ const handle = await open(path, constants.O_RDONLY | DIRECTORY | NOFOLLOW);
232
+ const opened = await handle.stat();
233
+ if (!opened.isDirectory() || !sameIdentity(before, opened)) {
234
+ await handle.close();
235
+ fail('unsafe-state');
236
+ }
237
+ return handle;
238
+ }
239
+
240
+ async function atomicWrite(paths, path, state) {
241
+ const source = `${JSON.stringify(state)}\n`;
242
+ if (Buffer.byteLength(source) > MAX_BYTES) fail('unsafe-state');
243
+ const temporary = join(paths.instanceDir, `.${basename(path)}.${randomUUID()}.tmp`);
244
+ const targetBefore = await fileMetadata(path);
245
+ const directory = await openPrivateDirectory(paths.instanceDir);
246
+ let handle;
247
+ let created;
248
+ try {
249
+ handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | NOFOLLOW, 0o600);
250
+ created = await handle.stat();
251
+ if (!privateRegular(created) || created.size !== 0) fail('unsafe-state');
252
+ await handle.writeFile(source, 'utf8');
253
+ await handle.sync();
254
+ const written = await handle.stat();
255
+ if (!privateRegular(written) || !sameIdentity(created, written) || written.size !== Buffer.byteLength(source)) fail('unsafe-state');
256
+ await handle.close();
257
+ handle = null;
258
+ await verifyResolvedStatePaths(paths);
259
+ const targetNow = await fileMetadata(path);
260
+ if ((targetBefore === null) !== (targetNow === null)
261
+ || (targetBefore && !sameIdentity(targetBefore, targetNow))) fail('unsafe-state');
262
+ const currentTemp = await lstat(temporary);
263
+ if (!privateRegular(currentTemp) || !sameIdentity(written, currentTemp)) fail('unsafe-state');
264
+ await rename(temporary, path);
265
+ try { await directory.sync(); } catch { fail('unsafe-state'); }
266
+ const persisted = await fileMetadata(path);
267
+ if (!persisted || !sameIdentity(persisted, written)) fail('unsafe-state');
268
+ } catch (error) {
269
+ if (created) {
270
+ try {
271
+ const current = await lstat(temporary);
272
+ if (privateRegular(current) && sameIdentity(current, created)) await unlink(temporary);
273
+ } catch {}
274
+ }
275
+ if (error instanceof ReservationError) throw error;
276
+ fail('unsafe-state');
277
+ } finally {
278
+ await handle?.close();
279
+ await directory.close();
280
+ }
281
+ }
282
+
283
+ async function acquireWithRetry(path) {
284
+ const deadline = Date.now() + 2_000;
285
+ while (true) {
286
+ try { return await acquireLock(path); } catch (error) {
287
+ if (!(error instanceof LockBusyError) || Date.now() >= deadline) throw error;
288
+ await delay(5);
289
+ }
290
+ }
291
+ }
292
+
293
+ function collides(left, right) {
294
+ const sameRepository = left.repositoryId === right.repositoryId;
295
+ if (!sameRepository) return false;
296
+ const folded = value => value.toLocaleLowerCase('en-US');
297
+ const leftPath = folded(left.worktreePath);
298
+ const rightPath = folded(right.worktreePath);
299
+ const worktreeOverlap = leftPath === rightPath
300
+ || leftPath.startsWith(`${rightPath}${sep}`)
301
+ || rightPath.startsWith(`${leftPath}${sep}`);
302
+ return folded(left.nodeId) === folded(right.nodeId)
303
+ || folded(left.branch) === folded(right.branch)
304
+ || worktreeOverlap
305
+ || responsibilitiesOverlap(left.responsibilities, right.responsibilities);
306
+ }
307
+
308
+ function validateLeaseIdentity(value) {
309
+ const identity = captureObject(value, new Set(['nodeId', 'ownerId', 'leaseId']), ['nodeId', 'ownerId', 'leaseId']);
310
+ if (!ID.test(identity.nodeId) || !ID.test(identity.ownerId) || !LEASE_ID.test(identity.leaseId)) fail('lease-mismatch');
311
+ return identity;
312
+ }
313
+
314
+ function operationOptions(value, allowClock) {
315
+ const allowed = new Set(allowClock ? ['expectedVersion', 'nowMs'] : ['expectedVersion']);
316
+ const options = captureObject(value, allowed, ['expectedVersion']);
317
+ if (!Number.isSafeInteger(options.expectedVersion) || options.expectedVersion < 0
318
+ || (allowClock && options.nowMs !== undefined && (!Number.isSafeInteger(options.nowMs) || options.nowMs < 0))) {
319
+ fail('invalid-reservation');
320
+ }
321
+ return Object.freeze(options);
322
+ }
323
+
324
+ function recoveryOptions(value) {
325
+ const options = captureObject(value, new Set(['expectedVersion', 'nowMs']), ['expectedVersion', 'nowMs']);
326
+ if (!Number.isSafeInteger(options.expectedVersion) || options.expectedVersion < 0
327
+ || !Number.isSafeInteger(options.nowMs) || options.nowMs < 0) fail('invalid-reservation');
328
+ return Object.freeze(options);
329
+ }
330
+
331
+ export function createReservationStore(paths) {
332
+ assertResolvedStatePaths(paths);
333
+ const path = join(paths.instanceDir, RESERVATION_FILE);
334
+
335
+ async function withLock(operation) {
336
+ await verifyResolvedStatePaths(paths);
337
+ const lock = await acquireWithRetry(paths.lockPath);
338
+ try {
339
+ await verifyResolvedStatePaths(paths);
340
+ return await operation();
341
+ } finally {
342
+ await lock.release();
343
+ }
344
+ }
345
+
346
+ return Object.freeze({
347
+ async readOnly() {
348
+ await verifyResolvedStatePaths(paths);
349
+ const state = await readState(path);
350
+ await verifyResolvedStatePaths(paths);
351
+ return Object.freeze({ version: state.version, reservations: Object.freeze(state.reservations.map(cloneReservation)) });
352
+ },
353
+
354
+ async list() {
355
+ return withLock(async () => {
356
+ const state = await readState(path);
357
+ return Object.freeze({ version: state.version, reservations: Object.freeze(state.reservations.map(cloneReservation)) });
358
+ });
359
+ },
360
+
361
+ async reserve(input, options = {}) {
362
+ const capturedOptions = operationOptions(options, true);
363
+ const candidate = captureReservation(input, capturedOptions);
364
+ const expectedVersion = capturedOptions.expectedVersion;
365
+ const nowMs = capturedOptions.nowMs ?? Date.now();
366
+ return withLock(async () => {
367
+ const state = await readState(path);
368
+ if (state.version !== expectedVersion) fail('state-version-conflict');
369
+ const collision = state.reservations.find(item => collides(item, candidate));
370
+ if (collision) {
371
+ if (Date.parse(collision.expiresAt) <= nowMs) fail('stale-lease');
372
+ fail('lease-collision');
373
+ }
374
+ if (state.version >= Number.MAX_SAFE_INTEGER) fail('state-exhausted');
375
+ if (state.reservations.length >= MAX_RESERVATIONS) fail('invalid-reservation');
376
+ const next = { schemaVersion: 1, version: state.version + 1, reservations: [...state.reservations, candidate] };
377
+ await atomicWrite(paths, path, next);
378
+ return Object.freeze({ version: next.version, reservation: cloneReservation(candidate) });
379
+ });
380
+ },
381
+
382
+ async activate(identityInput, options = {}) {
383
+ const identity = validateLeaseIdentity(identityInput);
384
+ const expectedVersion = operationOptions(options, false).expectedVersion;
385
+ return withLock(async () => {
386
+ const state = await readState(path);
387
+ if (state.version !== expectedVersion) fail('state-version-conflict');
388
+ const index = state.reservations.findIndex(item => item.leaseId === identity.leaseId);
389
+ const found = state.reservations[index];
390
+ if (!found || found.nodeId !== identity.nodeId || found.ownerId !== identity.ownerId || found.status !== 'reserved') fail('lease-mismatch');
391
+ if (state.version >= Number.MAX_SAFE_INTEGER) fail('state-exhausted');
392
+ const active = cloneReservation({ ...found, status: 'active' });
393
+ const reservations = [...state.reservations];
394
+ reservations[index] = active;
395
+ const next = { schemaVersion: 1, version: state.version + 1, reservations };
396
+ await atomicWrite(paths, path, next);
397
+ return Object.freeze({ version: next.version, reservation: active });
398
+ });
399
+ },
400
+
401
+ async rollback(identityInput, options = {}) {
402
+ const identity = validateLeaseIdentity(identityInput);
403
+ const expectedVersion = operationOptions(options, false).expectedVersion;
404
+ return withLock(async () => {
405
+ const state = await readState(path);
406
+ if (state.version !== expectedVersion) fail('state-version-conflict');
407
+ const found = state.reservations.find(item => item.leaseId === identity.leaseId);
408
+ if (!found || found.nodeId !== identity.nodeId || found.ownerId !== identity.ownerId || found.status !== 'reserved') fail('lease-mismatch');
409
+ if (state.version >= Number.MAX_SAFE_INTEGER) fail('state-exhausted');
410
+ const next = {
411
+ schemaVersion: 1,
412
+ version: state.version + 1,
413
+ reservations: state.reservations.filter(item => item.leaseId !== identity.leaseId),
414
+ };
415
+ await atomicWrite(paths, path, next);
416
+ return Object.freeze({ version: next.version, removed: true });
417
+ });
418
+ },
419
+
420
+ async recoverExpired(identityInput, options = {}) {
421
+ const identity = validateLeaseIdentity(identityInput);
422
+ const capturedOptions = recoveryOptions(options);
423
+ return withLock(async () => {
424
+ const state = await readState(path);
425
+ if (state.version !== capturedOptions.expectedVersion) fail('state-version-conflict');
426
+ const found = state.reservations.find(item => item.leaseId === identity.leaseId);
427
+ if (!found || found.nodeId !== identity.nodeId || found.ownerId !== identity.ownerId) fail('lease-mismatch');
428
+ if (Date.parse(found.expiresAt) > capturedOptions.nowMs) fail('lease-not-expired');
429
+ if (state.version >= Number.MAX_SAFE_INTEGER) fail('state-exhausted');
430
+ const next = {
431
+ schemaVersion: 1,
432
+ version: state.version + 1,
433
+ reservations: state.reservations.filter(item => item.leaseId !== identity.leaseId),
434
+ };
435
+ await atomicWrite(paths, path, next);
436
+ return Object.freeze({ version: next.version, removed: true, priorStatus: found.status });
437
+ });
438
+ },
439
+
440
+ async get(identityInput) {
441
+ const identity = validateLeaseIdentity(identityInput);
442
+ return withLock(async () => {
443
+ const state = await readState(path);
444
+ const found = state.reservations.find(item => item.leaseId === identity.leaseId);
445
+ if (!found || found.nodeId !== identity.nodeId || found.ownerId !== identity.ownerId) fail('lease-mismatch');
446
+ return Object.freeze({ version: state.version, reservation: cloneReservation(found) });
447
+ });
448
+ },
449
+ });
450
+ }