@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.
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/integrations/skills/pappra/SKILL.md +68 -0
- package/assets/integrations/skills/pappra/references/hosts.md +30 -0
- package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
- package/assets/integrations/skills/pappra/references/operations.md +14 -0
- package/assets/manifest.json +4 -0
- package/dist/app/collaboration.d.ts +170 -0
- package/dist/app/collaboration.js +1650 -0
- package/dist/app/doctor.d.ts +9 -0
- package/dist/app/doctor.js +1030 -0
- package/dist/app/init.d.ts +143 -0
- package/dist/app/init.js +1111 -0
- package/dist/cli/commands/code.d.ts +16 -0
- package/dist/cli/commands/code.js +127 -0
- package/dist/cli/commands/collaboration.d.ts +13 -0
- package/dist/cli/commands/collaboration.js +186 -0
- package/dist/cli/commands/continuity.d.ts +29 -0
- package/dist/cli/commands/continuity.js +133 -0
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +133 -0
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +57 -0
- package/dist/cli/commands/host.d.ts +13 -0
- package/dist/cli/commands/host.js +125 -0
- package/dist/cli/commands/init.d.ts +51 -0
- package/dist/cli/commands/init.js +668 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +10 -0
- package/dist/cli/output.d.ts +4 -0
- package/dist/cli/output.js +1 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +597 -0
- package/dist/code/budget.d.ts +14 -0
- package/dist/code/budget.js +39 -0
- package/dist/code/exec.d.ts +23 -0
- package/dist/code/exec.js +133 -0
- package/dist/code/identity.d.ts +13 -0
- package/dist/code/identity.js +214 -0
- package/dist/code/index.d.ts +6 -0
- package/dist/code/index.js +3 -0
- package/dist/code/providers/codegraph.d.ts +59 -0
- package/dist/code/providers/codegraph.js +567 -0
- package/dist/code/registration.d.ts +9 -0
- package/dist/code/registration.js +293 -0
- package/dist/code/service.d.ts +53 -0
- package/dist/code/service.js +633 -0
- package/dist/code/text.d.ts +3 -0
- package/dist/code/text.js +202 -0
- package/dist/collab/admission.d.ts +450 -0
- package/dist/collab/admission.js +797 -0
- package/dist/collab/canonical.d.ts +26 -0
- package/dist/collab/canonical.js +136 -0
- package/dist/collab/contracts.d.ts +73 -0
- package/dist/collab/contracts.js +76 -0
- package/dist/collab/envelope.d.ts +52 -0
- package/dist/collab/envelope.js +241 -0
- package/dist/collab/fingerprint.d.ts +7 -0
- package/dist/collab/fingerprint.js +58 -0
- package/dist/collab/handoff.d.ts +350 -0
- package/dist/collab/handoff.js +513 -0
- package/dist/collab/index.d.ts +9 -0
- package/dist/collab/index.js +8 -0
- package/dist/collab/lifecycle.d.ts +449 -0
- package/dist/collab/lifecycle.js +534 -0
- package/dist/collab/messages.d.ts +148 -0
- package/dist/collab/messages.js +583 -0
- package/dist/collab/notify.d.ts +47 -0
- package/dist/collab/notify.js +140 -0
- package/dist/collab/preflight.d.ts +31 -0
- package/dist/collab/preflight.js +271 -0
- package/dist/collab/resources.d.ts +119 -0
- package/dist/collab/resources.js +451 -0
- package/dist/collab/store.d.ts +143 -0
- package/dist/collab/store.js +606 -0
- package/dist/collab/types.d.ts +354 -0
- package/dist/collab/types.js +1 -0
- package/dist/collab/validation.d.ts +10 -0
- package/dist/collab/validation.js +303 -0
- package/dist/continuity/context.d.ts +24 -0
- package/dist/continuity/context.js +179 -0
- package/dist/continuity/index.d.ts +3 -0
- package/dist/continuity/index.js +1 -0
- package/dist/continuity/paths.d.ts +37 -0
- package/dist/continuity/paths.js +258 -0
- package/dist/continuity/service.d.ts +59 -0
- package/dist/continuity/service.js +1258 -0
- package/dist/hosts/claude/native.d.ts +79 -0
- package/dist/hosts/claude/native.js +853 -0
- package/dist/hosts/codex/native.d.ts +345 -0
- package/dist/hosts/codex/native.js +1528 -0
- package/dist/hosts/codex/protocol.d.ts +51 -0
- package/dist/hosts/codex/protocol.js +42 -0
- package/dist/hosts/detect.d.ts +28 -0
- package/dist/hosts/detect.js +73 -0
- package/dist/hosts/index.d.ts +10 -0
- package/dist/hosts/index.js +10 -0
- package/dist/hosts/integration/bundle.d.ts +16 -0
- package/dist/hosts/integration/bundle.js +81 -0
- package/dist/hosts/integration/hook-entry.d.ts +1 -0
- package/dist/hosts/integration/hook-entry.js +68 -0
- package/dist/hosts/integration/hook.d.ts +32 -0
- package/dist/hosts/integration/hook.js +781 -0
- package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
- package/dist/hosts/integration/pi-extension-runtime.js +89 -0
- package/dist/hosts/integration/pi-template.d.ts +3 -0
- package/dist/hosts/integration/pi-template.js +5 -0
- package/dist/hosts/integration/service.d.ts +41 -0
- package/dist/hosts/integration/service.js +1381 -0
- package/dist/hosts/integration/support.d.ts +36 -0
- package/dist/hosts/integration/support.js +41 -0
- package/dist/hosts/integration/templates.d.ts +22 -0
- package/dist/hosts/integration/templates.js +70 -0
- package/dist/hosts/pi/protocol.d.ts +22 -0
- package/dist/hosts/pi/protocol.js +28 -0
- package/dist/hosts/pi/query-result.d.ts +22 -0
- package/dist/hosts/pi/query-result.js +710 -0
- package/dist/hosts/pi/rpc.d.ts +937 -0
- package/dist/hosts/pi/rpc.js +1621 -0
- package/dist/hosts/pilot.d.ts +485 -0
- package/dist/hosts/pilot.js +593 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/infra/fs.d.ts +27 -0
- package/dist/infra/fs.js +130 -0
- package/dist/infra/git.d.ts +60 -0
- package/dist/infra/git.js +358 -0
- package/dist/infra/owned-process-group.d.ts +173 -0
- package/dist/infra/owned-process-group.js +571 -0
- package/dist/install/apply.d.ts +65 -0
- package/dist/install/apply.js +663 -0
- package/dist/install/index.d.ts +5 -0
- package/dist/install/index.js +5 -0
- package/dist/install/ownership.d.ts +85 -0
- package/dist/install/ownership.js +430 -0
- package/dist/install/plan.d.ts +39 -0
- package/dist/install/plan.js +97 -0
- package/dist/install/rollback.d.ts +18 -0
- package/dist/install/rollback.js +544 -0
- package/dist/install/runtime.d.ts +76 -0
- package/dist/install/runtime.js +1145 -0
- package/dist/knowledge/catalog.d.ts +47 -0
- package/dist/knowledge/catalog.js +308 -0
- package/dist/knowledge/context.d.ts +28 -0
- package/dist/knowledge/context.js +61 -0
- package/dist/knowledge/index.d.ts +3 -0
- package/dist/knowledge/index.js +1 -0
- package/dist/knowledge/journal.d.ts +16 -0
- package/dist/knowledge/journal.js +109 -0
- package/dist/knowledge/lifecycle.d.ts +8 -0
- package/dist/knowledge/lifecycle.js +1001 -0
- package/dist/knowledge/paths.d.ts +37 -0
- package/dist/knowledge/paths.js +236 -0
- package/dist/knowledge/refs.d.ts +36 -0
- package/dist/knowledge/refs.js +267 -0
- package/dist/knowledge/service.d.ts +21 -0
- package/dist/knowledge/service.js +393 -0
- package/dist/knowledge/types.d.ts +171 -0
- package/dist/knowledge/types.js +1 -0
- package/dist/knowledge/write.d.ts +8 -0
- package/dist/knowledge/write.js +1223 -0
- package/dist/package-info.d.ts +6 -0
- package/dist/package-info.js +19 -0
- package/dist/schemas/code.d.ts +377 -0
- package/dist/schemas/code.js +145 -0
- package/dist/schemas/collaboration.d.ts +115 -0
- package/dist/schemas/collaboration.js +106 -0
- package/dist/schemas/continuity.d.ts +315 -0
- package/dist/schemas/continuity.js +199 -0
- package/dist/schemas/diagnostics.d.ts +28 -0
- package/dist/schemas/diagnostics.js +27 -0
- package/dist/schemas/doctor.d.ts +157 -0
- package/dist/schemas/doctor.js +75 -0
- package/dist/schemas/document.d.ts +644 -0
- package/dist/schemas/document.js +296 -0
- package/dist/schemas/errors.d.ts +177 -0
- package/dist/schemas/errors.js +176 -0
- package/dist/schemas/evidence.d.ts +24 -0
- package/dist/schemas/evidence.js +16 -0
- package/dist/schemas/host.d.ts +436 -0
- package/dist/schemas/host.js +201 -0
- package/dist/schemas/index.d.ts +15 -0
- package/dist/schemas/index.js +15 -0
- package/dist/schemas/init.d.ts +121 -0
- package/dist/schemas/init.js +94 -0
- package/dist/schemas/install.d.ts +213 -0
- package/dist/schemas/install.js +139 -0
- package/dist/schemas/machine.d.ts +43 -0
- package/dist/schemas/machine.js +96 -0
- package/dist/schemas/outcome.d.ts +23 -0
- package/dist/schemas/outcome.js +17 -0
- package/dist/schemas/paths.d.ts +19 -0
- package/dist/schemas/paths.js +63 -0
- package/dist/schemas/workspace.d.ts +120 -0
- package/dist/schemas/workspace.js +201 -0
- package/dist/workspace/config-file.d.ts +36 -0
- package/dist/workspace/config-file.js +237 -0
- package/dist/workspace/config.d.ts +28 -0
- package/dist/workspace/config.js +79 -0
- package/dist/workspace/discover.d.ts +34 -0
- package/dist/workspace/discover.js +195 -0
- package/dist/workspace/documents.d.ts +21 -0
- package/dist/workspace/documents.js +395 -0
- package/dist/workspace/ignore.d.ts +10 -0
- package/dist/workspace/ignore.js +48 -0
- package/dist/workspace/index.d.ts +7 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/repositories.d.ts +24 -0
- package/dist/workspace/repositories.js +411 -0
- package/dist/workspace/resolve.d.ts +24 -0
- package/dist/workspace/resolve.js +268 -0
- package/dist/workspace/types.d.ts +71 -0
- package/dist/workspace/types.js +1 -0
- package/dist/workspace/validation.d.ts +18 -0
- package/dist/workspace/validation.js +197 -0
- package/package.json +105 -4
- package/skills/pappra/SKILL.md +68 -0
- package/skills/pappra/references/hosts.md +30 -0
- package/skills/pappra/references/lifecycle.md +37 -0
- package/skills/pappra/references/operations.md +14 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { errorCode, parseJson, object } from './validation.js';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { claimsOverlap, describeResource } from './canonical.js';
|
|
6
|
+
import { createNonce, nowIso, readRecord, transact, STATE_SCHEMA, } from './store.js';
|
|
7
|
+
// Resource claims are checked in one explicit resource domain: every task record under
|
|
8
|
+
// the same domain root is scanned, so two task-roots cannot each believe they own the
|
|
9
|
+
// same real path. Tasks outside that domain root are not covered and are not claimed
|
|
10
|
+
// to be protected.
|
|
11
|
+
export const DOMAIN_LOCK = '.domain.lock';
|
|
12
|
+
export class DomainLockHeldError extends Error {
|
|
13
|
+
holder;
|
|
14
|
+
constructor(message, holder) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.holder = holder;
|
|
17
|
+
this.name = 'DomainLockHeldError';
|
|
18
|
+
this.holder = holder;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function acquireDomainLock(domainRoot, { waitMs = 5000, pollMs = 20, holder = {}, } = {}) {
|
|
22
|
+
const lockDir = path.join(domainRoot, DOMAIN_LOCK);
|
|
23
|
+
return acquireLockWithDirectory(domainRoot, lockDir, {
|
|
24
|
+
waitMs,
|
|
25
|
+
pollMs,
|
|
26
|
+
holder,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function releaseDomainLock(domainRoot, holder) {
|
|
30
|
+
releaseLockDirectory(path.join(domainRoot, DOMAIN_LOCK), holder);
|
|
31
|
+
}
|
|
32
|
+
function acquireLockWithDirectory(domainRoot, lockDir, options) {
|
|
33
|
+
const nonce = options?.nonce ?? createNonce();
|
|
34
|
+
const deadline = Date.now() + (options?.waitMs ?? 5000);
|
|
35
|
+
const poll = options?.pollMs ?? 20;
|
|
36
|
+
for (;;) {
|
|
37
|
+
try {
|
|
38
|
+
fs.mkdirSync(lockDir, { mode: 0o700 });
|
|
39
|
+
const holder = {
|
|
40
|
+
schema: 'harness-collab/lock/1',
|
|
41
|
+
scope: 'resource-domain',
|
|
42
|
+
domain_root: domainRoot,
|
|
43
|
+
nonce,
|
|
44
|
+
pid: process.pid,
|
|
45
|
+
at: nowIso(),
|
|
46
|
+
...(options?.holder ?? {}),
|
|
47
|
+
};
|
|
48
|
+
fs.writeFileSync(path.join(lockDir, 'holder.json'), `${JSON.stringify(holder, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
49
|
+
return holder;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (errorCode(error) !== 'EEXIST')
|
|
53
|
+
throw error;
|
|
54
|
+
if (Date.now() >= deadline)
|
|
55
|
+
throw new DomainLockHeldError('Resource-domain lock is already held; the lock is never taken over by time', null);
|
|
56
|
+
const until = Date.now() + poll;
|
|
57
|
+
while (Date.now() < until) {
|
|
58
|
+
// Bounded wait; the domain lock has no TTL.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function releaseLockDirectory(lockDir, holder) {
|
|
64
|
+
try {
|
|
65
|
+
const current = parseJson(fs.readFileSync(path.join(lockDir, 'holder.json'), 'utf8'));
|
|
66
|
+
if (!object(current))
|
|
67
|
+
throw new Error('Invalid domain lock holder');
|
|
68
|
+
if (holder?.nonce && current.nonce !== holder.nonce)
|
|
69
|
+
throw new Error('Refusing to release a domain lock held by another owner');
|
|
70
|
+
fs.rmSync(path.join(lockDir, 'holder.json'), { force: true });
|
|
71
|
+
fs.rmdirSync(lockDir);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (errorCode(error) !== 'ENOENT')
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the git index/HEAD resources implied by a path inside a work tree.
|
|
80
|
+
* Only per-worktree index/HEAD are derived automatically; a shared common-dir
|
|
81
|
+
* operation must be claimed explicitly so ordinary edits in two worktrees stay parallel.
|
|
82
|
+
*/
|
|
83
|
+
export function gitResourcesFor(targetPath) {
|
|
84
|
+
let probe = targetPath;
|
|
85
|
+
for (;;) {
|
|
86
|
+
const dotGit = path.join(probe, '.git');
|
|
87
|
+
let stat = null;
|
|
88
|
+
try {
|
|
89
|
+
stat = fs.lstatSync(dotGit);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
stat = null;
|
|
93
|
+
}
|
|
94
|
+
if (stat?.isDirectory())
|
|
95
|
+
return gitResourcesFromDir(fs.realpathSync(dotGit), probe);
|
|
96
|
+
if (stat?.isFile()) {
|
|
97
|
+
const content = fs.readFileSync(dotGit, 'utf8').trim();
|
|
98
|
+
const match = /^gitdir:\s*(.+)$/.exec(content);
|
|
99
|
+
if (!match)
|
|
100
|
+
return [];
|
|
101
|
+
const resolved = path.resolve(probe, match[1].trim());
|
|
102
|
+
if (!fs.existsSync(resolved))
|
|
103
|
+
return [];
|
|
104
|
+
return gitResourcesFromDir(resolved, probe);
|
|
105
|
+
}
|
|
106
|
+
const parent = path.dirname(probe);
|
|
107
|
+
if (parent === probe)
|
|
108
|
+
return [];
|
|
109
|
+
probe = parent;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function gitResourcesFromDir(gitDir, workTree) {
|
|
113
|
+
const resources = [
|
|
114
|
+
{
|
|
115
|
+
kind: 'git-index',
|
|
116
|
+
path: path.join(gitDir, 'index'),
|
|
117
|
+
label: `git index of ${workTree}`,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
kind: 'git-head',
|
|
121
|
+
path: path.join(gitDir, 'HEAD'),
|
|
122
|
+
label: `git HEAD of ${workTree}`,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
try {
|
|
126
|
+
const common = fs
|
|
127
|
+
.readFileSync(path.join(gitDir, 'commondir'), 'utf8')
|
|
128
|
+
.trim();
|
|
129
|
+
const commonDir = fs.realpathSync(path.resolve(gitDir, common));
|
|
130
|
+
resources.push({
|
|
131
|
+
kind: 'git-common-dir',
|
|
132
|
+
path: commonDir,
|
|
133
|
+
scope: 'tree',
|
|
134
|
+
derived: true,
|
|
135
|
+
shared: true,
|
|
136
|
+
label: `shared git dir of ${workTree}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Not a linked work tree: the git dir is already the common dir.
|
|
141
|
+
}
|
|
142
|
+
return resources;
|
|
143
|
+
}
|
|
144
|
+
export function expandResources(requested, { includeGit = true, includeSharedGit = false } = {}) {
|
|
145
|
+
// One claim per canonical key. A path reached through several requests, a repeated path, a
|
|
146
|
+
// symlink alias, or an explicit/derived pair must not become two claims on one real
|
|
147
|
+
// resource, because the second claim would then be unreachable through its shared id.
|
|
148
|
+
// Requested metadata wins over derived metadata for the same key; the first occurrence
|
|
149
|
+
// fixes the reported order.
|
|
150
|
+
const sources = [];
|
|
151
|
+
for (const item of requested)
|
|
152
|
+
sources.push({ item, descriptor: describeResource(item) });
|
|
153
|
+
const positionByKey = new Map();
|
|
154
|
+
const expanded = [];
|
|
155
|
+
const add = (entry) => {
|
|
156
|
+
const position = positionByKey.get(entry.key);
|
|
157
|
+
if (position === undefined) {
|
|
158
|
+
positionByKey.set(entry.key, expanded.length);
|
|
159
|
+
expanded.push(entry);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (expanded[position]?.origin === 'derived' &&
|
|
163
|
+
entry.origin === 'requested')
|
|
164
|
+
expanded[position] = entry;
|
|
165
|
+
};
|
|
166
|
+
for (const { item, descriptor } of sources)
|
|
167
|
+
add({ ...descriptor, origin: item.origin ?? 'requested' });
|
|
168
|
+
if (includeGit) {
|
|
169
|
+
for (const { descriptor } of sources) {
|
|
170
|
+
if (descriptor.kind !== 'path' || descriptor.scope !== 'tree')
|
|
171
|
+
continue;
|
|
172
|
+
if (typeof descriptor.path !== 'string')
|
|
173
|
+
throw new Error('Path resource has no path');
|
|
174
|
+
for (const derived of gitResourcesFor(descriptor.path)) {
|
|
175
|
+
if (derived.kind === 'git-common-dir' && !includeSharedGit)
|
|
176
|
+
continue;
|
|
177
|
+
const described = describeResource(derived);
|
|
178
|
+
add({ ...described, origin: 'derived', derived_from: descriptor.key });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return expanded;
|
|
183
|
+
}
|
|
184
|
+
export function listDomainRecords(domainRoot) {
|
|
185
|
+
const tasksDirectory = path.join(domainRoot, 'tasks');
|
|
186
|
+
const records = [];
|
|
187
|
+
let names = [];
|
|
188
|
+
try {
|
|
189
|
+
names = fs.readdirSync(tasksDirectory).sort();
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (errorCode(error) === 'ENOENT')
|
|
193
|
+
return records;
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
for (const name of names) {
|
|
197
|
+
try {
|
|
198
|
+
const record = readRecord(path.join(tasksDirectory, name));
|
|
199
|
+
if (record && record.schema !== STATE_SCHEMA)
|
|
200
|
+
throw new Error('Unsupported task record schema');
|
|
201
|
+
if (record)
|
|
202
|
+
records.push(record);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// A record that cannot be read is reported by doctor, not silently treated as empty.
|
|
206
|
+
records.push({ task_id: name, __unreadable: true, claims: [] });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return records;
|
|
210
|
+
}
|
|
211
|
+
export function activeClaims(record) {
|
|
212
|
+
return (record.claims ?? []).filter((claim) => !claim.released_at);
|
|
213
|
+
}
|
|
214
|
+
export function scanConflicts(records, candidates, { access, holder }) {
|
|
215
|
+
const conflicts = [];
|
|
216
|
+
for (const record of records) {
|
|
217
|
+
if (record.__unreadable) {
|
|
218
|
+
// Unknown ownership is never treated as free ownership.
|
|
219
|
+
for (const candidate of candidates) {
|
|
220
|
+
conflicts.push({
|
|
221
|
+
resource_key: candidate.key,
|
|
222
|
+
kind: candidate.kind,
|
|
223
|
+
held_by: null,
|
|
224
|
+
held_task: record.task_id,
|
|
225
|
+
held_claim: null,
|
|
226
|
+
held_access: 'unknown',
|
|
227
|
+
relation: 'unknown_state',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
for (const claim of activeClaims(record)) {
|
|
233
|
+
for (const candidate of candidates) {
|
|
234
|
+
if (!claimsOverlap(claim, candidate))
|
|
235
|
+
continue;
|
|
236
|
+
const sameHolder = claim.holder?.agent_id === holder?.agent_id &&
|
|
237
|
+
claim.holder?.session_id === holder?.session_id;
|
|
238
|
+
if (sameHolder)
|
|
239
|
+
continue;
|
|
240
|
+
if (claim.access === 'read' && access === 'read')
|
|
241
|
+
continue;
|
|
242
|
+
conflicts.push({
|
|
243
|
+
resource_key: candidate.key,
|
|
244
|
+
kind: candidate.kind,
|
|
245
|
+
held_by: claim.holder,
|
|
246
|
+
held_task: record.task_id,
|
|
247
|
+
held_claim: claim.claim_id,
|
|
248
|
+
held_access: claim.access,
|
|
249
|
+
relation: claim.path && candidate.path
|
|
250
|
+
? claim.path === candidate.path
|
|
251
|
+
? 'same'
|
|
252
|
+
: 'overlapping'
|
|
253
|
+
: 'same_key',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return conflicts;
|
|
259
|
+
}
|
|
260
|
+
function nextResourceEpoch(records, keys) {
|
|
261
|
+
let epoch = 0;
|
|
262
|
+
for (const record of records) {
|
|
263
|
+
for (const claim of record.claims ?? []) {
|
|
264
|
+
if (keys.includes(claim.key))
|
|
265
|
+
epoch = Math.max(epoch, claim.resource_epoch ?? 0);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return epoch + 1;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Claim resources for one task. The domain lock serialises the decision so two tasks
|
|
272
|
+
* cannot both observe an empty domain and then claim the same path.
|
|
273
|
+
*/
|
|
274
|
+
export function claimResources(taskRoot, { resources, access = 'write', holder, waitMs = 5000, clock = Date.now(), includeSharedGit = false, note = null, subtaskId = null, attempt = null, }) {
|
|
275
|
+
const record = readRecord(taskRoot);
|
|
276
|
+
if (!record)
|
|
277
|
+
return { ok: false, reason: 'not_initialised' };
|
|
278
|
+
if (!['read', 'write'].includes(access))
|
|
279
|
+
return { ok: false, reason: 'invalid_access' };
|
|
280
|
+
if (!holder?.agent_id || !holder?.session_id)
|
|
281
|
+
return { ok: false, reason: 'holder_required' };
|
|
282
|
+
const domainRoot = record.domain_root;
|
|
283
|
+
const domainLock = acquireDomainLock(domainRoot, {
|
|
284
|
+
waitMs,
|
|
285
|
+
holder: { actor: holder.agent_id },
|
|
286
|
+
});
|
|
287
|
+
try {
|
|
288
|
+
const records = listDomainRecords(domainRoot);
|
|
289
|
+
const unreadable = records
|
|
290
|
+
.filter((item) => item.__unreadable)
|
|
291
|
+
.map((item) => item.task_id);
|
|
292
|
+
if (unreadable.length) {
|
|
293
|
+
// An unreadable record may hold exactly the resource being requested, so the domain
|
|
294
|
+
// state is unknown: refuse and require reconciliation instead of assuming it is free.
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
reason: 'domain_state_unknown',
|
|
298
|
+
unreadable_tasks: unreadable,
|
|
299
|
+
detail: 'at least one task record in this domain cannot be parsed; reconcile it before claiming',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
// Only a write claim implies the work-tree index/HEAD resources; a read claim does not.
|
|
303
|
+
const candidates = expandResources(resources, {
|
|
304
|
+
includeGit: access === 'write',
|
|
305
|
+
includeSharedGit: access === 'write' && includeSharedGit,
|
|
306
|
+
});
|
|
307
|
+
const conflicts = scanConflicts(records, candidates, { access, holder });
|
|
308
|
+
if (conflicts.length)
|
|
309
|
+
return { ok: false, reason: 'resource_conflict', conflicts };
|
|
310
|
+
const keys = candidates.map((item) => item.key);
|
|
311
|
+
const resourceEpoch = nextResourceEpoch(records, keys);
|
|
312
|
+
const at = nowIso(clock);
|
|
313
|
+
const created = candidates.map((item) => ({
|
|
314
|
+
claim_id: `claim-${item.kind}-${claimIdDigest([holder.agent_id, holder.session_id, item.key, resourceEpoch])}-${resourceEpoch}`,
|
|
315
|
+
key: item.key,
|
|
316
|
+
kind: item.kind,
|
|
317
|
+
scope: item.scope,
|
|
318
|
+
path: item.path ?? null,
|
|
319
|
+
port: item.port ?? null,
|
|
320
|
+
label: item.label,
|
|
321
|
+
origin: item.origin ?? 'requested',
|
|
322
|
+
derived_from: item.derived_from ?? null,
|
|
323
|
+
access,
|
|
324
|
+
holder: {
|
|
325
|
+
agent_id: holder.agent_id,
|
|
326
|
+
session_id: holder.session_id,
|
|
327
|
+
harness: holder.harness ?? null,
|
|
328
|
+
},
|
|
329
|
+
resource_epoch: resourceEpoch,
|
|
330
|
+
claimed_at: at,
|
|
331
|
+
released_at: null,
|
|
332
|
+
subtask_id: subtaskId ?? null,
|
|
333
|
+
attempt: Number.isSafeInteger(attempt) ? attempt : null,
|
|
334
|
+
note,
|
|
335
|
+
}));
|
|
336
|
+
const outcome = transact(taskRoot, {
|
|
337
|
+
actor: holder.agent_id,
|
|
338
|
+
purpose: 'claim',
|
|
339
|
+
waitMs,
|
|
340
|
+
clock,
|
|
341
|
+
apply: (current) => {
|
|
342
|
+
const fresh = scanConflicts([
|
|
343
|
+
current,
|
|
344
|
+
...records.filter((item) => item.task_id !== current.task_id),
|
|
345
|
+
], candidates, { access, holder });
|
|
346
|
+
if (fresh.length)
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
reason: 'resource_conflict',
|
|
350
|
+
conflicts: fresh,
|
|
351
|
+
};
|
|
352
|
+
current.claims = current.claims ?? [];
|
|
353
|
+
current.claims.push(...created);
|
|
354
|
+
return {
|
|
355
|
+
record: current,
|
|
356
|
+
op: 'claim',
|
|
357
|
+
summary: { keys, resource_epoch: resourceEpoch },
|
|
358
|
+
result: { claims: created },
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
if (!outcome.ok)
|
|
363
|
+
return outcome;
|
|
364
|
+
return {
|
|
365
|
+
ok: true,
|
|
366
|
+
claims: created,
|
|
367
|
+
resource_epoch: resourceEpoch,
|
|
368
|
+
conflicts: [],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
releaseDomainLock(domainRoot, domainLock);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function claimIdDigest(identity) {
|
|
376
|
+
// The identity tuple is JSON-encoded, not delimiter-joined: a session id or key that
|
|
377
|
+
// contains the delimiter could otherwise impersonate another tuple. The 128-bit digest
|
|
378
|
+
// replaces the previous 32-bit string hash, which collided for distinct keys (Aa/BB).
|
|
379
|
+
return crypto
|
|
380
|
+
.createHash('sha256')
|
|
381
|
+
.update(JSON.stringify(identity))
|
|
382
|
+
.digest('hex')
|
|
383
|
+
.slice(0, 32);
|
|
384
|
+
}
|
|
385
|
+
export function releaseClaim(taskRoot, { claimId, holder, reason, clock = Date.now(), }) {
|
|
386
|
+
const record = readRecord(taskRoot);
|
|
387
|
+
if (!record)
|
|
388
|
+
return { ok: false, reason: 'not_initialised' };
|
|
389
|
+
const outcome = transact(taskRoot, {
|
|
390
|
+
actor: holder?.agent_id ?? 'unattributed',
|
|
391
|
+
purpose: 'release',
|
|
392
|
+
clock,
|
|
393
|
+
apply: (current) => {
|
|
394
|
+
const matches = (current.claims ?? []).filter((item) => item.claim_id === claimId);
|
|
395
|
+
if (!matches.length)
|
|
396
|
+
return { ok: false, reason: 'unknown_claim' };
|
|
397
|
+
if (matches.length > 1) {
|
|
398
|
+
// Historical records can hold several claims under one 32-bit-hash id. Releasing
|
|
399
|
+
// "the" claim is then ambiguous: a released alias could coexist with an active
|
|
400
|
+
// claim, or the wrong holder could be selected. Refuse before any mutation.
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
reason: 'ambiguous_claim_id',
|
|
404
|
+
claim_id: claimId,
|
|
405
|
+
matches: matches.length,
|
|
406
|
+
active_matches: matches.filter((item) => !item.released_at).length,
|
|
407
|
+
detail: 'this record stores more than one claim under the same id; repair the record explicitly instead of releasing through an ambiguous id',
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
const claim = matches[0];
|
|
411
|
+
if (claim.released_at)
|
|
412
|
+
return { ok: false, reason: 'already_released' };
|
|
413
|
+
if (holder &&
|
|
414
|
+
(claim.holder?.agent_id !== holder.agent_id ||
|
|
415
|
+
claim.holder?.session_id !== holder.session_id)) {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
reason: 'holder_mismatch',
|
|
419
|
+
held_by: claim.holder,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (claim.cancel_pending)
|
|
423
|
+
return {
|
|
424
|
+
ok: false,
|
|
425
|
+
reason: 'cancel_unconfirmed',
|
|
426
|
+
detail: 'a cancel request does not prove the holder stopped',
|
|
427
|
+
};
|
|
428
|
+
claim.released_at = nowIso(clock);
|
|
429
|
+
claim.released_by = holder?.agent_id ?? 'unattributed';
|
|
430
|
+
claim.release_reason = reason ?? null;
|
|
431
|
+
return {
|
|
432
|
+
record: current,
|
|
433
|
+
op: 'release',
|
|
434
|
+
summary: { claim_id: claimId },
|
|
435
|
+
result: { claim_id: claimId },
|
|
436
|
+
};
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
return outcome.ok ? { ok: true, claim_id: claimId } : outcome;
|
|
440
|
+
}
|
|
441
|
+
export function domainClaims(record) {
|
|
442
|
+
return listDomainRecords(record.domain_root)
|
|
443
|
+
.map((item) => ({
|
|
444
|
+
task_id: item.task_id,
|
|
445
|
+
claims: activeClaims(item).map((claim) => ({
|
|
446
|
+
...claim,
|
|
447
|
+
task_id: item.task_id,
|
|
448
|
+
})),
|
|
449
|
+
}))
|
|
450
|
+
.filter((item) => item.claims.length);
|
|
451
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { TaskRecord, Coordinator, Mutation, Failure, LockHolder, TransactionFile, Finding, TransactionResult } from './types.js';
|
|
2
|
+
export declare const STATE_SCHEMA = "harness-collab/state/1";
|
|
3
|
+
export declare const LEDGER_SCHEMA = "harness-collab/ledger/1";
|
|
4
|
+
export declare const RECORD_FILE = "task.json";
|
|
5
|
+
export declare const LEDGER_FILE = "ledger.jsonl";
|
|
6
|
+
export declare const DEFAULT_DOMAIN_ROOT: string;
|
|
7
|
+
export declare class LockHeldError extends Error {
|
|
8
|
+
readonly holder: LockHolder | null;
|
|
9
|
+
constructor(message: string, holder: LockHolder | null);
|
|
10
|
+
}
|
|
11
|
+
export declare class RevisionConflictError extends Error {
|
|
12
|
+
readonly expected: number;
|
|
13
|
+
readonly actual: number;
|
|
14
|
+
constructor(expected: number, actual: number);
|
|
15
|
+
}
|
|
16
|
+
export declare const digestBytes: (value: string | Uint8Array) => string;
|
|
17
|
+
export declare const digestText: (value: string | Uint8Array) => string;
|
|
18
|
+
export declare function createNonce(): string;
|
|
19
|
+
export declare function nowIso(clock?: number): string;
|
|
20
|
+
export declare function isAbsoluteExistingDirectory(value: unknown): boolean;
|
|
21
|
+
export declare function resolveDomainRoot(input?: string | null): string;
|
|
22
|
+
export declare function taskRootFor(domainRoot: string, taskId: string): string;
|
|
23
|
+
export declare const recordPath: (taskRoot: string) => string;
|
|
24
|
+
export declare const ledgerPath: (taskRoot: string) => string;
|
|
25
|
+
export declare const lockPath: (taskRoot: string) => string;
|
|
26
|
+
export declare const messagesDir: (taskRoot: string) => string;
|
|
27
|
+
export declare const resultsDir: (taskRoot: string) => string;
|
|
28
|
+
export declare const nativeDir: (taskRoot: string) => string;
|
|
29
|
+
export declare const abortedDir: (taskRoot: string) => string;
|
|
30
|
+
export declare const brokenLocksDir: (taskRoot: string) => string;
|
|
31
|
+
export declare function domainIdOf(domainRoot: string): string;
|
|
32
|
+
/** Validate the stored shape without inferring native execution facts. */
|
|
33
|
+
export declare function readRecord(taskRoot: string): TaskRecord | null;
|
|
34
|
+
export declare function readLock(taskRoot: string): {
|
|
35
|
+
held: boolean;
|
|
36
|
+
holder?: never;
|
|
37
|
+
holder_process_alive?: never;
|
|
38
|
+
transaction_files?: never;
|
|
39
|
+
} | {
|
|
40
|
+
held: boolean;
|
|
41
|
+
holder: LockHolder | null;
|
|
42
|
+
holder_process_alive: boolean;
|
|
43
|
+
transaction_files: string[];
|
|
44
|
+
};
|
|
45
|
+
export declare function listTransactions(taskRoot: string): TransactionFile[];
|
|
46
|
+
/**
|
|
47
|
+
* Acquire the record lock. `waitMs` bounds how long the caller waits; the lock itself
|
|
48
|
+
* never expires and is never stolen.
|
|
49
|
+
*/
|
|
50
|
+
export declare function acquireLock(taskRoot: string, { actor, purpose, waitMs, pollMs, }?: {
|
|
51
|
+
actor?: string;
|
|
52
|
+
purpose?: string;
|
|
53
|
+
waitMs?: number;
|
|
54
|
+
pollMs?: number;
|
|
55
|
+
}): {
|
|
56
|
+
schema: string;
|
|
57
|
+
nonce: string;
|
|
58
|
+
actor: string;
|
|
59
|
+
purpose: string;
|
|
60
|
+
pid: number;
|
|
61
|
+
hostname: string;
|
|
62
|
+
acquired_at: string;
|
|
63
|
+
};
|
|
64
|
+
export declare function releaseLock(taskRoot: string, holder: LockHolder | null): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Break a lock only when the caller states the holder is stopped. The lock directory is
|
|
67
|
+
* preserved as evidence; it is never deleted silently and never expires on its own.
|
|
68
|
+
*/
|
|
69
|
+
export declare function breakLock(taskRoot: string, { actor, reason, confirmHolderStopped, at, }?: {
|
|
70
|
+
actor?: string;
|
|
71
|
+
reason?: string;
|
|
72
|
+
confirmHolderStopped?: boolean;
|
|
73
|
+
at?: string;
|
|
74
|
+
}): {
|
|
75
|
+
broken: boolean;
|
|
76
|
+
reason: string;
|
|
77
|
+
evidence?: never;
|
|
78
|
+
record?: never;
|
|
79
|
+
} | {
|
|
80
|
+
reason?: never;
|
|
81
|
+
broken: boolean;
|
|
82
|
+
evidence: string;
|
|
83
|
+
record: {
|
|
84
|
+
schema: string;
|
|
85
|
+
actor: string;
|
|
86
|
+
reason: string;
|
|
87
|
+
confirm_holder_stopped: boolean;
|
|
88
|
+
at: string;
|
|
89
|
+
previous_holder: LockHolder | null | undefined;
|
|
90
|
+
previous_holder_process_alive: boolean | undefined;
|
|
91
|
+
uncommitted_transactions: string[];
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
/** Archive a leftover transaction file as evidence. A leftover file proves no commit happened. */
|
|
95
|
+
export declare function archiveAbortedTransaction(taskRoot: string, file: string, { actor, at, }?: {
|
|
96
|
+
actor?: string;
|
|
97
|
+
reason?: string;
|
|
98
|
+
confirmHolderStopped?: boolean;
|
|
99
|
+
at?: string;
|
|
100
|
+
}): string;
|
|
101
|
+
/**
|
|
102
|
+
* Run one read-validate-write commit on the record.
|
|
103
|
+
* `apply(record)` returns `{record, op, summary, result}`; returning `null` aborts the
|
|
104
|
+
* transaction without touching the record. Returns `{ok, ...}`; revision conflicts and
|
|
105
|
+
* held locks are reported instead of thrown so callers can reconcile.
|
|
106
|
+
*/
|
|
107
|
+
export declare function transact<T = unknown>(taskRoot: string, { actor, purpose, expectRevision, waitMs, apply, clock, }: {
|
|
108
|
+
actor?: string;
|
|
109
|
+
purpose?: string;
|
|
110
|
+
expectRevision?: number | null | undefined;
|
|
111
|
+
waitMs?: number;
|
|
112
|
+
apply: (record: TaskRecord) => Mutation<T> | Failure | null | undefined;
|
|
113
|
+
clock?: number;
|
|
114
|
+
}): TransactionResult<T>;
|
|
115
|
+
export declare function newRecord({ taskId, domainRoot, taskRoot, coordinator, clock, }: {
|
|
116
|
+
taskId: string;
|
|
117
|
+
domainRoot: string;
|
|
118
|
+
taskRoot: string;
|
|
119
|
+
coordinator?: Coordinator | null;
|
|
120
|
+
clock?: number;
|
|
121
|
+
}): TaskRecord;
|
|
122
|
+
export declare function initTask(taskRoot: string, { taskId, coordinator, domainRoot: explicitDomainRoot, clock, }: {
|
|
123
|
+
taskId: string;
|
|
124
|
+
coordinator?: Coordinator | null;
|
|
125
|
+
domainRoot?: string | null;
|
|
126
|
+
clock?: number;
|
|
127
|
+
}): {
|
|
128
|
+
created: boolean;
|
|
129
|
+
record: TaskRecord | null;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Inspect the record and its lock for interrupted work. Reporting only: nothing is
|
|
133
|
+
* repaired, restarted or released here.
|
|
134
|
+
*/
|
|
135
|
+
export declare function doctor(taskRoot: string): {
|
|
136
|
+
task_root: string;
|
|
137
|
+
state_revision: null;
|
|
138
|
+
findings: Finding[];
|
|
139
|
+
} | {
|
|
140
|
+
task_root: string;
|
|
141
|
+
state_revision: number;
|
|
142
|
+
findings: Finding[];
|
|
143
|
+
};
|