@phyzess/pappra 0.0.0-stage → 0.1.0-beta.2

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 (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/integrations/skills/pappra/SKILL.md +68 -0
  5. package/assets/integrations/skills/pappra/references/hosts.md +30 -0
  6. package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
  7. package/assets/integrations/skills/pappra/references/operations.md +14 -0
  8. package/assets/manifest.json +4 -0
  9. package/dist/app/collaboration.d.ts +170 -0
  10. package/dist/app/collaboration.js +1650 -0
  11. package/dist/app/doctor.d.ts +9 -0
  12. package/dist/app/doctor.js +1030 -0
  13. package/dist/app/init.d.ts +143 -0
  14. package/dist/app/init.js +1111 -0
  15. package/dist/cli/commands/code.d.ts +16 -0
  16. package/dist/cli/commands/code.js +127 -0
  17. package/dist/cli/commands/collaboration.d.ts +13 -0
  18. package/dist/cli/commands/collaboration.js +186 -0
  19. package/dist/cli/commands/continuity.d.ts +29 -0
  20. package/dist/cli/commands/continuity.js +133 -0
  21. package/dist/cli/commands/docs.d.ts +11 -0
  22. package/dist/cli/commands/docs.js +133 -0
  23. package/dist/cli/commands/doctor.d.ts +14 -0
  24. package/dist/cli/commands/doctor.js +57 -0
  25. package/dist/cli/commands/host.d.ts +13 -0
  26. package/dist/cli/commands/host.js +125 -0
  27. package/dist/cli/commands/init.d.ts +51 -0
  28. package/dist/cli/commands/init.js +668 -0
  29. package/dist/cli/main.d.ts +2 -0
  30. package/dist/cli/main.js +10 -0
  31. package/dist/cli/output.d.ts +4 -0
  32. package/dist/cli/output.js +1 -0
  33. package/dist/cli/run.d.ts +2 -0
  34. package/dist/cli/run.js +597 -0
  35. package/dist/code/budget.d.ts +14 -0
  36. package/dist/code/budget.js +39 -0
  37. package/dist/code/exec.d.ts +23 -0
  38. package/dist/code/exec.js +133 -0
  39. package/dist/code/identity.d.ts +13 -0
  40. package/dist/code/identity.js +214 -0
  41. package/dist/code/index.d.ts +6 -0
  42. package/dist/code/index.js +3 -0
  43. package/dist/code/providers/codegraph.d.ts +59 -0
  44. package/dist/code/providers/codegraph.js +567 -0
  45. package/dist/code/registration.d.ts +9 -0
  46. package/dist/code/registration.js +293 -0
  47. package/dist/code/service.d.ts +53 -0
  48. package/dist/code/service.js +633 -0
  49. package/dist/code/text.d.ts +3 -0
  50. package/dist/code/text.js +202 -0
  51. package/dist/collab/admission.d.ts +450 -0
  52. package/dist/collab/admission.js +797 -0
  53. package/dist/collab/canonical.d.ts +26 -0
  54. package/dist/collab/canonical.js +136 -0
  55. package/dist/collab/contracts.d.ts +73 -0
  56. package/dist/collab/contracts.js +76 -0
  57. package/dist/collab/envelope.d.ts +52 -0
  58. package/dist/collab/envelope.js +241 -0
  59. package/dist/collab/fingerprint.d.ts +7 -0
  60. package/dist/collab/fingerprint.js +58 -0
  61. package/dist/collab/handoff.d.ts +350 -0
  62. package/dist/collab/handoff.js +513 -0
  63. package/dist/collab/index.d.ts +9 -0
  64. package/dist/collab/index.js +8 -0
  65. package/dist/collab/lifecycle.d.ts +449 -0
  66. package/dist/collab/lifecycle.js +534 -0
  67. package/dist/collab/messages.d.ts +148 -0
  68. package/dist/collab/messages.js +583 -0
  69. package/dist/collab/notify.d.ts +47 -0
  70. package/dist/collab/notify.js +140 -0
  71. package/dist/collab/preflight.d.ts +31 -0
  72. package/dist/collab/preflight.js +271 -0
  73. package/dist/collab/resources.d.ts +119 -0
  74. package/dist/collab/resources.js +451 -0
  75. package/dist/collab/store.d.ts +143 -0
  76. package/dist/collab/store.js +606 -0
  77. package/dist/collab/types.d.ts +354 -0
  78. package/dist/collab/types.js +1 -0
  79. package/dist/collab/validation.d.ts +10 -0
  80. package/dist/collab/validation.js +303 -0
  81. package/dist/continuity/context.d.ts +24 -0
  82. package/dist/continuity/context.js +179 -0
  83. package/dist/continuity/index.d.ts +3 -0
  84. package/dist/continuity/index.js +1 -0
  85. package/dist/continuity/paths.d.ts +37 -0
  86. package/dist/continuity/paths.js +258 -0
  87. package/dist/continuity/service.d.ts +59 -0
  88. package/dist/continuity/service.js +1258 -0
  89. package/dist/hosts/claude/native.d.ts +79 -0
  90. package/dist/hosts/claude/native.js +853 -0
  91. package/dist/hosts/codex/native.d.ts +345 -0
  92. package/dist/hosts/codex/native.js +1528 -0
  93. package/dist/hosts/codex/protocol.d.ts +51 -0
  94. package/dist/hosts/codex/protocol.js +42 -0
  95. package/dist/hosts/detect.d.ts +28 -0
  96. package/dist/hosts/detect.js +73 -0
  97. package/dist/hosts/index.d.ts +10 -0
  98. package/dist/hosts/index.js +10 -0
  99. package/dist/hosts/integration/bundle.d.ts +16 -0
  100. package/dist/hosts/integration/bundle.js +81 -0
  101. package/dist/hosts/integration/hook-entry.d.ts +1 -0
  102. package/dist/hosts/integration/hook-entry.js +68 -0
  103. package/dist/hosts/integration/hook.d.ts +32 -0
  104. package/dist/hosts/integration/hook.js +781 -0
  105. package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
  106. package/dist/hosts/integration/pi-extension-runtime.js +89 -0
  107. package/dist/hosts/integration/pi-template.d.ts +3 -0
  108. package/dist/hosts/integration/pi-template.js +5 -0
  109. package/dist/hosts/integration/service.d.ts +41 -0
  110. package/dist/hosts/integration/service.js +1381 -0
  111. package/dist/hosts/integration/support.d.ts +36 -0
  112. package/dist/hosts/integration/support.js +41 -0
  113. package/dist/hosts/integration/templates.d.ts +22 -0
  114. package/dist/hosts/integration/templates.js +70 -0
  115. package/dist/hosts/pi/protocol.d.ts +22 -0
  116. package/dist/hosts/pi/protocol.js +28 -0
  117. package/dist/hosts/pi/query-result.d.ts +22 -0
  118. package/dist/hosts/pi/query-result.js +710 -0
  119. package/dist/hosts/pi/rpc.d.ts +937 -0
  120. package/dist/hosts/pi/rpc.js +1621 -0
  121. package/dist/hosts/pilot.d.ts +485 -0
  122. package/dist/hosts/pilot.js +593 -0
  123. package/dist/index.d.ts +5 -0
  124. package/dist/index.js +4 -0
  125. package/dist/infra/fs.d.ts +27 -0
  126. package/dist/infra/fs.js +130 -0
  127. package/dist/infra/git.d.ts +60 -0
  128. package/dist/infra/git.js +358 -0
  129. package/dist/infra/owned-process-group.d.ts +173 -0
  130. package/dist/infra/owned-process-group.js +571 -0
  131. package/dist/install/apply.d.ts +65 -0
  132. package/dist/install/apply.js +663 -0
  133. package/dist/install/index.d.ts +5 -0
  134. package/dist/install/index.js +5 -0
  135. package/dist/install/ownership.d.ts +85 -0
  136. package/dist/install/ownership.js +430 -0
  137. package/dist/install/plan.d.ts +39 -0
  138. package/dist/install/plan.js +97 -0
  139. package/dist/install/rollback.d.ts +18 -0
  140. package/dist/install/rollback.js +544 -0
  141. package/dist/install/runtime.d.ts +76 -0
  142. package/dist/install/runtime.js +1145 -0
  143. package/dist/knowledge/catalog.d.ts +47 -0
  144. package/dist/knowledge/catalog.js +308 -0
  145. package/dist/knowledge/context.d.ts +28 -0
  146. package/dist/knowledge/context.js +61 -0
  147. package/dist/knowledge/index.d.ts +3 -0
  148. package/dist/knowledge/index.js +1 -0
  149. package/dist/knowledge/journal.d.ts +16 -0
  150. package/dist/knowledge/journal.js +109 -0
  151. package/dist/knowledge/lifecycle.d.ts +8 -0
  152. package/dist/knowledge/lifecycle.js +1001 -0
  153. package/dist/knowledge/paths.d.ts +37 -0
  154. package/dist/knowledge/paths.js +236 -0
  155. package/dist/knowledge/refs.d.ts +36 -0
  156. package/dist/knowledge/refs.js +267 -0
  157. package/dist/knowledge/service.d.ts +21 -0
  158. package/dist/knowledge/service.js +393 -0
  159. package/dist/knowledge/types.d.ts +171 -0
  160. package/dist/knowledge/types.js +1 -0
  161. package/dist/knowledge/write.d.ts +8 -0
  162. package/dist/knowledge/write.js +1223 -0
  163. package/dist/package-info.d.ts +6 -0
  164. package/dist/package-info.js +19 -0
  165. package/dist/schemas/code.d.ts +377 -0
  166. package/dist/schemas/code.js +145 -0
  167. package/dist/schemas/collaboration.d.ts +115 -0
  168. package/dist/schemas/collaboration.js +106 -0
  169. package/dist/schemas/continuity.d.ts +315 -0
  170. package/dist/schemas/continuity.js +199 -0
  171. package/dist/schemas/diagnostics.d.ts +28 -0
  172. package/dist/schemas/diagnostics.js +27 -0
  173. package/dist/schemas/doctor.d.ts +157 -0
  174. package/dist/schemas/doctor.js +75 -0
  175. package/dist/schemas/document.d.ts +644 -0
  176. package/dist/schemas/document.js +296 -0
  177. package/dist/schemas/errors.d.ts +177 -0
  178. package/dist/schemas/errors.js +176 -0
  179. package/dist/schemas/evidence.d.ts +24 -0
  180. package/dist/schemas/evidence.js +16 -0
  181. package/dist/schemas/host.d.ts +436 -0
  182. package/dist/schemas/host.js +201 -0
  183. package/dist/schemas/index.d.ts +15 -0
  184. package/dist/schemas/index.js +15 -0
  185. package/dist/schemas/init.d.ts +121 -0
  186. package/dist/schemas/init.js +94 -0
  187. package/dist/schemas/install.d.ts +213 -0
  188. package/dist/schemas/install.js +139 -0
  189. package/dist/schemas/machine.d.ts +43 -0
  190. package/dist/schemas/machine.js +96 -0
  191. package/dist/schemas/outcome.d.ts +23 -0
  192. package/dist/schemas/outcome.js +17 -0
  193. package/dist/schemas/paths.d.ts +19 -0
  194. package/dist/schemas/paths.js +63 -0
  195. package/dist/schemas/workspace.d.ts +120 -0
  196. package/dist/schemas/workspace.js +201 -0
  197. package/dist/workspace/config-file.d.ts +36 -0
  198. package/dist/workspace/config-file.js +237 -0
  199. package/dist/workspace/config.d.ts +28 -0
  200. package/dist/workspace/config.js +79 -0
  201. package/dist/workspace/discover.d.ts +34 -0
  202. package/dist/workspace/discover.js +195 -0
  203. package/dist/workspace/documents.d.ts +21 -0
  204. package/dist/workspace/documents.js +395 -0
  205. package/dist/workspace/ignore.d.ts +10 -0
  206. package/dist/workspace/ignore.js +48 -0
  207. package/dist/workspace/index.d.ts +7 -0
  208. package/dist/workspace/index.js +3 -0
  209. package/dist/workspace/repositories.d.ts +24 -0
  210. package/dist/workspace/repositories.js +411 -0
  211. package/dist/workspace/resolve.d.ts +24 -0
  212. package/dist/workspace/resolve.js +268 -0
  213. package/dist/workspace/types.d.ts +71 -0
  214. package/dist/workspace/types.js +1 -0
  215. package/dist/workspace/validation.d.ts +18 -0
  216. package/dist/workspace/validation.js +197 -0
  217. package/package.json +105 -4
  218. package/skills/pappra/SKILL.md +68 -0
  219. package/skills/pappra/references/hosts.md +30 -0
  220. package/skills/pappra/references/lifecycle.md +37 -0
  221. package/skills/pappra/references/operations.md +14 -0
@@ -0,0 +1,606 @@
1
+ import { errorCode, parseJson, object, isTaskRecord, isLockHolder, } from './validation.js';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ // Task collaboration record. The record is data, never an executable script, and it
7
+ // stays separate from harness session stores and generated registry manifests.
8
+ //
9
+ // Concurrency model (asserted, not implied):
10
+ // * Mutual exclusion is a directory created with `mkdir`, which is atomic on POSIX.
11
+ // There is no lock TTL and no time-based takeover.
12
+ // * Read-validate-write happens while holding that lock, so the expected-revision
13
+ // check is a real compare-and-swap over the record, not a best-effort reread.
14
+ // * Commit is write-then-rename inside the lock directory. A surviving transaction
15
+ // file proves the rename never happened, so an interrupted commit is detectable
16
+ // and never leaves a partially written record.
17
+ // * The lock is released explicitly. A crash leaves it in place for inspection.
18
+ export const STATE_SCHEMA = 'harness-collab/state/1';
19
+ export const LEDGER_SCHEMA = 'harness-collab/ledger/1';
20
+ export const RECORD_FILE = 'task.json';
21
+ export const LEDGER_FILE = 'ledger.jsonl';
22
+ export const DEFAULT_DOMAIN_ROOT = path.join(os.homedir(), '.local', 'state', 'pappra', 'domains', 'default');
23
+ export class LockHeldError extends Error {
24
+ holder;
25
+ constructor(message, holder) {
26
+ super(message);
27
+ this.holder = holder;
28
+ this.name = 'LockHeldError';
29
+ this.holder = holder;
30
+ }
31
+ }
32
+ export class RevisionConflictError extends Error {
33
+ expected;
34
+ actual;
35
+ constructor(expected, actual) {
36
+ super(`state_revision conflict: expected ${expected}, found ${actual}`);
37
+ this.expected = expected;
38
+ this.actual = actual;
39
+ this.name = 'RevisionConflictError';
40
+ this.expected = expected;
41
+ this.actual = actual;
42
+ }
43
+ }
44
+ export const digestBytes = (value) => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
45
+ export const digestText = (value) => digestBytes(value);
46
+ export function createNonce() {
47
+ return crypto.randomBytes(12).toString('hex');
48
+ }
49
+ export function nowIso(clock = Date.now()) {
50
+ const date = new Date(clock);
51
+ const offset = -date.getTimezoneOffset();
52
+ const sign = offset >= 0 ? '+' : '-';
53
+ const abs = Math.abs(offset);
54
+ // Shift the instant by the local offset so the wall-clock part and the offset suffix
55
+ // agree; appending an offset to a UTC instant would misstate the time by that offset.
56
+ const shifted = new Date(date.getTime() + offset * 60_000);
57
+ return shifted
58
+ .toISOString()
59
+ .replace('Z', `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`);
60
+ }
61
+ export function isAbsoluteExistingDirectory(value) {
62
+ if (typeof value !== 'string' || !path.isAbsolute(value))
63
+ return false;
64
+ try {
65
+ return fs.statSync(value).isDirectory();
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ export function resolveDomainRoot(input) {
72
+ const candidate = input ?? process.env.PAPPRA_DOMAIN_ROOT ?? DEFAULT_DOMAIN_ROOT;
73
+ if (!path.isAbsolute(candidate))
74
+ throw new Error('Domain root must be an absolute path');
75
+ fs.mkdirSync(candidate, { recursive: true, mode: 0o700 });
76
+ return fs.realpathSync(candidate);
77
+ }
78
+ export function taskRootFor(domainRoot, taskId) {
79
+ if (typeof taskId !== 'string' ||
80
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId)) {
81
+ throw new Error('task_id must be a short identifier (letters, digits, dot, dash, underscore)');
82
+ }
83
+ return path.join(resolveDomainRoot(domainRoot), 'tasks', taskId);
84
+ }
85
+ export const recordPath = (taskRoot) => path.join(taskRoot, RECORD_FILE);
86
+ export const ledgerPath = (taskRoot) => path.join(taskRoot, LEDGER_FILE);
87
+ export const lockPath = (taskRoot) => path.join(taskRoot, '.write.lock');
88
+ export const messagesDir = (taskRoot) => path.join(taskRoot, 'messages');
89
+ export const resultsDir = (taskRoot) => path.join(taskRoot, 'results');
90
+ export const nativeDir = (taskRoot) => path.join(taskRoot, 'native');
91
+ export const abortedDir = (taskRoot) => path.join(taskRoot, 'aborted');
92
+ export const brokenLocksDir = (taskRoot) => path.join(taskRoot, 'broken-locks');
93
+ export function domainIdOf(domainRoot) {
94
+ return `domain:${crypto.createHash('sha256').update(domainRoot).digest('hex').slice(0, 24)}`;
95
+ }
96
+ /** Validate the stored shape without inferring native execution facts. */
97
+ export function readRecord(taskRoot) {
98
+ const file = recordPath(taskRoot);
99
+ let stat;
100
+ try {
101
+ stat = fs.statSync(file);
102
+ }
103
+ catch (error) {
104
+ if (errorCode(error) === 'ENOENT')
105
+ return null;
106
+ throw error;
107
+ }
108
+ if (!stat.isFile() || stat.size > 8 * 1024 * 1024)
109
+ throw new Error('Task record is not a supported file');
110
+ const parsed = parseJson(fs.readFileSync(file, 'utf8'));
111
+ if (!isTaskRecord(parsed))
112
+ throw new Error('Invalid task record structure');
113
+ return parsed;
114
+ }
115
+ function writeJsonAtomic(file, value) {
116
+ const temporary = `${file}.tmp-${createNonce()}`;
117
+ const handle = fs.openSync(temporary, 'wx', 0o600);
118
+ try {
119
+ fs.writeSync(handle, `${JSON.stringify(value, null, 2)}\n`);
120
+ fs.fsyncSync(handle);
121
+ }
122
+ finally {
123
+ fs.closeSync(handle);
124
+ }
125
+ fs.renameSync(temporary, file);
126
+ return value;
127
+ }
128
+ export function readLock(taskRoot) {
129
+ const directory = lockPath(taskRoot);
130
+ try {
131
+ if (!fs.statSync(directory).isDirectory())
132
+ return { held: false };
133
+ }
134
+ catch {
135
+ return { held: false };
136
+ }
137
+ let holder = null;
138
+ try {
139
+ const parsed = parseJson(fs.readFileSync(path.join(directory, 'holder.json'), 'utf8'));
140
+ holder = isLockHolder(parsed) ? parsed : null;
141
+ }
142
+ catch {
143
+ holder = null;
144
+ }
145
+ let alive = false;
146
+ if (holder?.hostname === os.hostname() && Number.isSafeInteger(holder.pid)) {
147
+ try {
148
+ process.kill(holder.pid, 0);
149
+ alive = true;
150
+ }
151
+ catch (error) {
152
+ alive = errorCode(error) === 'EPERM';
153
+ }
154
+ }
155
+ return {
156
+ held: true,
157
+ holder,
158
+ holder_process_alive: alive,
159
+ transaction_files: listTransactions(taskRoot).map((item) => item.file),
160
+ };
161
+ }
162
+ export function listTransactions(taskRoot) {
163
+ const directory = lockPath(taskRoot);
164
+ const found = [];
165
+ let names = [];
166
+ try {
167
+ names = fs.readdirSync(directory);
168
+ }
169
+ catch {
170
+ return found;
171
+ }
172
+ for (const name of names
173
+ .filter((item) => item.startsWith('txn-') &&
174
+ item.endsWith('.json') &&
175
+ !item.endsWith('.meta.json'))
176
+ .sort()) {
177
+ const file = path.join(directory, name);
178
+ let record = null;
179
+ let meta = null;
180
+ try {
181
+ record = parseJson(fs.readFileSync(file, 'utf8'));
182
+ }
183
+ catch {
184
+ record = null;
185
+ }
186
+ try {
187
+ meta = parseJson(fs.readFileSync(file.replace(/\.json$/, '.meta.json'), 'utf8'));
188
+ }
189
+ catch {
190
+ meta = null;
191
+ }
192
+ found.push({
193
+ file,
194
+ revision: object(record) ? (record.state_revision ?? null) : null,
195
+ purpose: object(meta) ? (meta.purpose ?? null) : null,
196
+ actor: object(meta) ? (meta.actor ?? null) : null,
197
+ });
198
+ }
199
+ return found;
200
+ }
201
+ /**
202
+ * Acquire the record lock. `waitMs` bounds how long the caller waits; the lock itself
203
+ * never expires and is never stolen.
204
+ */
205
+ export function acquireLock(taskRoot, { actor = 'unattributed', purpose = 'unspecified', waitMs = 5000, pollMs = 20, } = {}) {
206
+ fs.mkdirSync(taskRoot, { recursive: true, mode: 0o700 });
207
+ const directory = lockPath(taskRoot);
208
+ const deadline = Date.now() + Math.max(0, waitMs);
209
+ const nonce = createNonce();
210
+ for (;;) {
211
+ try {
212
+ fs.mkdirSync(directory, { mode: 0o700 });
213
+ const holder = {
214
+ schema: 'harness-collab/lock/1',
215
+ nonce,
216
+ actor,
217
+ purpose,
218
+ pid: process.pid,
219
+ hostname: os.hostname(),
220
+ acquired_at: nowIso(),
221
+ };
222
+ fs.writeFileSync(path.join(directory, 'holder.json'), `${JSON.stringify(holder, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
223
+ return holder;
224
+ }
225
+ catch (error) {
226
+ if (errorCode(error) !== 'EEXIST')
227
+ throw error;
228
+ const current = readLock(taskRoot);
229
+ if (Date.now() >= deadline) {
230
+ throw new LockHeldError('Task record lock is already held; the lock is never taken over by time', current.holder ?? null);
231
+ }
232
+ const until = Date.now() + pollMs;
233
+ while (Date.now() < until) {
234
+ // Bounded busy wait keeps the helper dependency-free and deterministic.
235
+ }
236
+ }
237
+ }
238
+ }
239
+ export function releaseLock(taskRoot, holder) {
240
+ const directory = lockPath(taskRoot);
241
+ const record = readLock(taskRoot);
242
+ if (!record.held)
243
+ return false;
244
+ if (holder?.nonce && record.holder?.nonce !== holder.nonce) {
245
+ throw new Error('Refusing to release a lock held by a different owner');
246
+ }
247
+ for (const item of listTransactions(taskRoot)) {
248
+ throw new Error(`Refusing to release the lock with an uncommitted transaction: ${path.basename(item.file)}`);
249
+ }
250
+ fs.rmSync(path.join(directory, 'holder.json'), { force: true });
251
+ fs.rmdirSync(directory);
252
+ return true;
253
+ }
254
+ /**
255
+ * Break a lock only when the caller states the holder is stopped. The lock directory is
256
+ * preserved as evidence; it is never deleted silently and never expires on its own.
257
+ */
258
+ export function breakLock(taskRoot, { actor, reason, confirmHolderStopped, at = nowIso(), } = {}) {
259
+ if (confirmHolderStopped !== true)
260
+ throw new Error('Breaking a lock requires an explicit confirmation that the holder stopped');
261
+ if (typeof actor !== 'string' || !actor.trim())
262
+ throw new Error('Breaking a lock requires an actor');
263
+ if (typeof reason !== 'string' || !reason.trim())
264
+ throw new Error('Breaking a lock requires a reason');
265
+ const directory = lockPath(taskRoot);
266
+ const before = readLock(taskRoot);
267
+ if (!before.held)
268
+ return { broken: false, reason: 'no_lock' };
269
+ const destination = path.join(brokenLocksDir(taskRoot), `${at.replace(/[:.]/g, '-')}-${createNonce()}`);
270
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
271
+ fs.renameSync(directory, destination);
272
+ const record = {
273
+ schema: 'harness-collab/lock-break/1',
274
+ actor,
275
+ reason,
276
+ confirm_holder_stopped: true,
277
+ at,
278
+ previous_holder: before.holder,
279
+ previous_holder_process_alive: before.holder_process_alive,
280
+ uncommitted_transactions: (before.transaction_files ?? []).map((file) => path.basename(file)),
281
+ };
282
+ fs.writeFileSync(path.join(destination, 'break-record.json'), `${JSON.stringify(record, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
283
+ return { broken: true, evidence: destination, record };
284
+ }
285
+ /** Archive a leftover transaction file as evidence. A leftover file proves no commit happened. */
286
+ export function archiveAbortedTransaction(taskRoot, file, { actor, at = nowIso(), } = {}) {
287
+ const suffix = path.basename(file);
288
+ const destination = path.join(abortedDir(taskRoot), `${at.replace(/[:.]/g, '-')}-${suffix}`);
289
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
290
+ fs.renameSync(file, destination);
291
+ const metaSource = file.replace(/\.json$/, '.meta.json');
292
+ try {
293
+ parseJson(fs.readFileSync(metaSource, 'utf8'));
294
+ fs.renameSync(metaSource, destination.replace(/\.json$/, '.meta.json'));
295
+ }
296
+ catch { }
297
+ fs.writeFileSync(`${destination}.note.json`, `${JSON.stringify({
298
+ schema: 'harness-collab/aborted-transaction/1',
299
+ actor: actor ?? 'unattributed',
300
+ at,
301
+ note: 'Commit never happened: the transaction file still existed, so the rename did not run.',
302
+ }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
303
+ return destination;
304
+ }
305
+ function assertRevision(expected, actual) {
306
+ if (expected === undefined || expected === null)
307
+ return;
308
+ if (!Number.isSafeInteger(expected))
309
+ throw new Error('expect_revision must be an integer');
310
+ if (expected !== actual)
311
+ throw new RevisionConflictError(expected, actual);
312
+ }
313
+ /**
314
+ * Run one read-validate-write commit on the record.
315
+ * `apply(record)` returns `{record, op, summary, result}`; returning `null` aborts the
316
+ * transaction without touching the record. Returns `{ok, ...}`; revision conflicts and
317
+ * held locks are reported instead of thrown so callers can reconcile.
318
+ */
319
+ export function transact(taskRoot, { actor = 'unattributed', purpose = 'unspecified', expectRevision, waitMs = 5000, apply, clock = Date.now(), }) {
320
+ if (typeof apply !== 'function')
321
+ throw new Error('transact requires an apply function');
322
+ let holder;
323
+ try {
324
+ holder = acquireLock(taskRoot, { actor, purpose, waitMs });
325
+ }
326
+ catch (error) {
327
+ if (error instanceof LockHeldError)
328
+ return { ok: false, reason: 'lock_held', holder: error.holder };
329
+ throw error;
330
+ }
331
+ let committed = false;
332
+ try {
333
+ const current = readRecord(taskRoot);
334
+ if (!current)
335
+ return { ok: false, reason: 'not_initialised' };
336
+ if (current.schema !== STATE_SCHEMA)
337
+ return { ok: false, reason: 'unsupported_schema' };
338
+ assertRevision(expectRevision, current.state_revision);
339
+ const outcome = apply(structuredClone(current));
340
+ if (outcome === null || outcome === undefined)
341
+ return {
342
+ ok: false,
343
+ reason: 'rejected',
344
+ state_revision: current.state_revision,
345
+ };
346
+ // A refusal that must still be recorded (a stale-epoch attempt, for example) sets
347
+ // `commit: true`: the rejection is committed, the operation is still reported as refused.
348
+ if (outcome.ok === false && outcome.commit !== true)
349
+ return {
350
+ ...outcome,
351
+ ok: false,
352
+ reason: typeof outcome.reason === 'string' ? outcome.reason : 'rejected',
353
+ state_revision: current.state_revision,
354
+ };
355
+ const rejected = outcome.ok === false;
356
+ if (!outcome.record || !isTaskRecord(outcome.record))
357
+ throw new Error('Mutation must return a valid task record');
358
+ const next = outcome.record;
359
+ next.schema = STATE_SCHEMA;
360
+ next.state_revision = current.state_revision + 1;
361
+ next.updated_at = nowIso(clock);
362
+ // The transaction file holds the next record itself, so the rename either commits
363
+ // that exact record or leaves the previous one untouched.
364
+ const transactionFile = path.join(lockPath(taskRoot), `txn-${current.state_revision + 1}-${holder.nonce}.json`);
365
+ const metadataFile = path.join(lockPath(taskRoot), `txn-${current.state_revision + 1}-${holder.nonce}.meta.json`);
366
+ const handle = fs.openSync(transactionFile, 'wx', 0o600);
367
+ try {
368
+ fs.writeSync(handle, `${JSON.stringify(next, null, 2)}\n`);
369
+ fs.fsyncSync(handle);
370
+ }
371
+ finally {
372
+ fs.closeSync(handle);
373
+ }
374
+ fs.writeFileSync(metadataFile, `${JSON.stringify({
375
+ schema: 'harness-collab/transaction/1',
376
+ purpose,
377
+ actor,
378
+ base_revision: current.state_revision,
379
+ }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
380
+ if (process.env.PAPPRA_FAULT === 'exit_before_rename') {
381
+ // Documented test-only fault injection: leave an uncommitted transaction behind.
382
+ process.exit(70);
383
+ }
384
+ fs.renameSync(transactionFile, recordPath(taskRoot));
385
+ fs.rmSync(metadataFile, { force: true });
386
+ committed = true;
387
+ const rejection = rejected
388
+ ? {
389
+ ...outcome,
390
+ ok: false,
391
+ reason: typeof outcome.reason === 'string' ? outcome.reason : 'rejected',
392
+ committed: true,
393
+ }
394
+ : null;
395
+ const ledgerLine = {
396
+ schema: LEDGER_SCHEMA,
397
+ revision: next.state_revision,
398
+ at: next.updated_at,
399
+ actor,
400
+ purpose,
401
+ op: outcome.op ?? purpose,
402
+ summary: outcome.summary ?? null,
403
+ record_digest: digestText(JSON.stringify(next)),
404
+ };
405
+ fs.appendFileSync(ledgerPath(taskRoot), `${JSON.stringify(ledgerLine)}\n`, {
406
+ mode: 0o600,
407
+ });
408
+ if (rejection)
409
+ return {
410
+ ...rejection,
411
+ state_revision: next.state_revision,
412
+ result: outcome.result ?? null,
413
+ op: ledgerLine.op,
414
+ };
415
+ return {
416
+ ok: true,
417
+ state_revision: next.state_revision,
418
+ result: outcome.result ?? null,
419
+ op: ledgerLine.op,
420
+ };
421
+ }
422
+ catch (error) {
423
+ if (error instanceof RevisionConflictError) {
424
+ return {
425
+ ok: false,
426
+ reason: 'revision_conflict',
427
+ expected: error.expected,
428
+ actual: error.actual,
429
+ };
430
+ }
431
+ throw error;
432
+ }
433
+ finally {
434
+ if (committed) {
435
+ for (const item of listTransactions(taskRoot)) {
436
+ // A committed transaction is gone; anything left here is an interrupted sibling commit.
437
+ archiveAbortedTransaction(taskRoot, item.file, {
438
+ actor,
439
+ at: nowIso(clock),
440
+ });
441
+ }
442
+ for (const name of fs
443
+ .readdirSync(lockPath(taskRoot))
444
+ .filter((entry) => entry.endsWith('.meta.json'))) {
445
+ const sibling = path.join(lockPath(taskRoot), name.replace(/\.meta\.json$/, '.json'));
446
+ // A metadata file whose transaction already committed carries no uncommitted work.
447
+ if (!fs.existsSync(sibling))
448
+ fs.rmSync(path.join(lockPath(taskRoot), name), { force: true });
449
+ }
450
+ }
451
+ releaseLock(taskRoot, holder);
452
+ }
453
+ }
454
+ export function newRecord({ taskId, domainRoot, taskRoot, coordinator, clock = Date.now(), }) {
455
+ return {
456
+ schema: STATE_SCHEMA,
457
+ task_id: taskId,
458
+ domain_id: domainIdOf(domainRoot),
459
+ domain_root: domainRoot,
460
+ task_root: taskRoot,
461
+ created_at: nowIso(clock),
462
+ updated_at: nowIso(clock),
463
+ state_revision: 1,
464
+ coordinator: coordinator ?? null,
465
+ input_revision: null,
466
+ policy: {
467
+ unknown_fields: 'ignored',
468
+ unknown_message_type: 'rejected',
469
+ attempt_key: 'task_id + subtask_id + attempt',
470
+ single_native_manager: true,
471
+ },
472
+ attempts: [],
473
+ messages: [],
474
+ claims: [],
475
+ handoffs: [],
476
+ uncertainty: [],
477
+ results: [],
478
+ native: [],
479
+ };
480
+ }
481
+ export function initTask(taskRoot, { taskId, coordinator, domainRoot: explicitDomainRoot = null, clock = Date.now(), }) {
482
+ fs.mkdirSync(taskRoot, { recursive: true, mode: 0o700 });
483
+ const resolvedRoot = fs.realpathSync(taskRoot);
484
+ const file = recordPath(resolvedRoot);
485
+ if (fs.existsSync(file))
486
+ return { created: false, record: readRecord(resolvedRoot) };
487
+ const domainRoot = explicitDomainRoot
488
+ ? fs.realpathSync(resolveDomainRoot(explicitDomainRoot))
489
+ : fs.realpathSync(path.resolve(resolvedRoot, '..', '..'));
490
+ for (const directory of [
491
+ messagesDir(resolvedRoot),
492
+ resultsDir(resolvedRoot),
493
+ nativeDir(resolvedRoot),
494
+ brokenLocksDir(resolvedRoot),
495
+ abortedDir(resolvedRoot),
496
+ ]) {
497
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
498
+ }
499
+ const record = newRecord({
500
+ taskId,
501
+ domainRoot,
502
+ taskRoot: resolvedRoot,
503
+ coordinator: coordinator ?? null,
504
+ clock,
505
+ });
506
+ writeJsonAtomic(file, record);
507
+ fs.writeFileSync(ledgerPath(taskRoot), `${JSON.stringify({
508
+ schema: LEDGER_SCHEMA,
509
+ revision: 1,
510
+ at: record.created_at,
511
+ actor: coordinator?.agent_id ?? 'unattributed',
512
+ purpose: 'init',
513
+ op: 'init',
514
+ summary: null,
515
+ record_digest: digestText(JSON.stringify(record)),
516
+ })}\n`, { mode: 0o600 });
517
+ return { created: true, record };
518
+ }
519
+ /**
520
+ * Inspect the record and its lock for interrupted work. Reporting only: nothing is
521
+ * repaired, restarted or released here.
522
+ */
523
+ export function doctor(taskRoot) {
524
+ const findings = [];
525
+ const lock = readLock(taskRoot);
526
+ const record = readRecord(taskRoot);
527
+ if (lock.held) {
528
+ findings.push({
529
+ code: 'lock_held',
530
+ severity: lock.holder_process_alive ? 'active' : 'uncertain',
531
+ detail: `lock holder pid ${lock.holder?.pid ?? 'unknown'} on ${lock.holder?.hostname ?? 'unknown'}`,
532
+ holder: lock.holder,
533
+ });
534
+ }
535
+ for (const item of listTransactions(taskRoot)) {
536
+ findings.push({
537
+ code: 'uncommitted_transaction',
538
+ severity: 'uncertain',
539
+ detail: `transaction for revision ${item.revision ?? 'unknown'} was never committed`,
540
+ file: item.file,
541
+ });
542
+ }
543
+ if (!record) {
544
+ findings.push({
545
+ code: 'record_missing',
546
+ severity: 'invalid',
547
+ detail: 'task.json is absent',
548
+ });
549
+ return { task_root: taskRoot, state_revision: null, findings };
550
+ }
551
+ if (record.schema !== STATE_SCHEMA)
552
+ findings.push({
553
+ code: 'unsupported_schema',
554
+ severity: 'invalid',
555
+ detail: String(record.schema),
556
+ });
557
+ const applied = new Set((record.messages ?? []).map((message) => message.message_id));
558
+ let registered = [];
559
+ try {
560
+ registered = fs
561
+ .readdirSync(messagesDir(taskRoot))
562
+ .filter((name) => name.endsWith('.json'));
563
+ }
564
+ catch {
565
+ registered = [];
566
+ }
567
+ for (const name of registered) {
568
+ const messageId = name.slice(0, -'.json'.length);
569
+ if (!applied.has(messageId)) {
570
+ findings.push({
571
+ code: 'message_registered_not_applied',
572
+ severity: 'uncertain',
573
+ detail: `message ${messageId} exists on disk but is not in the record`,
574
+ message_id: messageId,
575
+ });
576
+ }
577
+ }
578
+ for (const entry of record.uncertainty ?? []) {
579
+ if (!entry.resolved_at)
580
+ findings.push({
581
+ code: 'open_uncertainty',
582
+ severity: 'uncertain',
583
+ detail: entry.kind ?? 'unknown',
584
+ uncertainty_id: entry.uncertainty_id,
585
+ });
586
+ }
587
+ for (const attempt of record.attempts ?? []) {
588
+ if (['assigned', 'accepted', 'running', 'unknown'].includes(attempt.state)) {
589
+ const observation = attempt.native?.observation ?? 'unknown';
590
+ if (observation !== 'confirmed') {
591
+ findings.push({
592
+ code: 'attempt_without_confirmed_native_state',
593
+ severity: 'uncertain',
594
+ detail: `${attempt.subtask_id}#${attempt.attempt} is ${attempt.state} with native observation ${observation}`,
595
+ subtask_id: attempt.subtask_id,
596
+ attempt: attempt.attempt,
597
+ });
598
+ }
599
+ }
600
+ }
601
+ return {
602
+ task_root: taskRoot,
603
+ state_revision: record.state_revision,
604
+ findings,
605
+ };
606
+ }