wowbagger 0.1.0-alpha.1
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/CHANGELOG.md +94 -0
- package/LICENSE +201 -0
- package/README.md +464 -0
- package/adapters/claude-code/entrypoint.js +19 -0
- package/adapters/claude-code/wowbagger-adapter.json +25 -0
- package/adapters/codex/entrypoint.js +11 -0
- package/adapters/codex/wowbagger-adapter.json +25 -0
- package/adapters/opencode/entrypoint.js +11 -0
- package/adapters/opencode/wowbagger-adapter.json +25 -0
- package/bin/wowbagger.js +7 -0
- package/package.json +51 -0
- package/skills/wowbagger/SKILL.md +136 -0
- package/src/adapter/approval.js +135 -0
- package/src/adapter/bootstrap.js +43 -0
- package/src/adapter/context.js +34 -0
- package/src/adapter/core-probe.js +231 -0
- package/src/adapter/describe.js +383 -0
- package/src/adapter/entrypoint-main.js +335 -0
- package/src/adapter/entrypoint-path.js +103 -0
- package/src/adapter/handoff.js +124 -0
- package/src/adapter/instructions.js +106 -0
- package/src/adapter/invoke.js +294 -0
- package/src/adapter/limits.js +26 -0
- package/src/adapter/manifest.js +93 -0
- package/src/adapter/messages.js +15 -0
- package/src/adapter/paths.js +88 -0
- package/src/adapter/process-outcome.js +1116 -0
- package/src/adapter/schema-helpers.js +60 -0
- package/src/claim-capabilities.js +54 -0
- package/src/claim-coordinator.js +85 -0
- package/src/claim-journal.js +236 -0
- package/src/claim-operations.js +138 -0
- package/src/claim-publication.js +739 -0
- package/src/claim-request.js +140 -0
- package/src/claim-store.js +198 -0
- package/src/cli.js +1130 -0
- package/src/dependencies.js +3 -0
- package/src/git-reconciliation.js +62 -0
- package/src/ledger.js +296 -0
- package/src/mint.js +32 -0
- package/src/mutation.js +1979 -0
- package/src/namespace.js +35 -0
- package/src/ready.js +85 -0
- package/src/request.js +246 -0
- package/src/schema-migration.js +300 -0
- package/src/validate.js +1208 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// src/claim-request.js
|
|
2
|
+
// Schema validation for work-claim requests (docs/work-claim-contract.md section 5).
|
|
3
|
+
// This intentionally re-implements the rules test/work-claim-reference.js's
|
|
4
|
+
// requestSchemaError encodes for the reference model — production code must not
|
|
5
|
+
// depend on test code, so the rules are duplicated here rather than imported.
|
|
6
|
+
import { pointer } from './request.js';
|
|
7
|
+
|
|
8
|
+
const NAMESPACE_ID = /^wbns_[a-f0-9]{32}$/;
|
|
9
|
+
const ITEM_ID = /^wb_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
10
|
+
const OWNER_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
|
11
|
+
const UTC_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})Z$/;
|
|
12
|
+
const MAX_EPOCH = 18446744073709551615n;
|
|
13
|
+
const MAX_LEASE_DURATION_MS = 86400000;
|
|
14
|
+
|
|
15
|
+
const REQUIRED_MEMBERS = {
|
|
16
|
+
read: ['ledger_namespace', 'item_id'],
|
|
17
|
+
acquire: ['ledger_namespace', 'item_id', 'owner_id', 'lease_duration_ms', 'expected'],
|
|
18
|
+
renew: ['ledger_namespace', 'item_id', 'owner_id', 'epoch', 'expected_expires_at', 'lease_duration_ms'],
|
|
19
|
+
release: ['ledger_namespace', 'item_id', 'owner_id', 'epoch', 'expected_expires_at'],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Returns an array of {path, code, message} issues (empty when the request is valid).
|
|
23
|
+
// The request must already be JSON-parsed and deep-normalized (plain objects/arrays,
|
|
24
|
+
// JsonNumber unwrapped) — this module only checks shape, not JSON syntax.
|
|
25
|
+
export function validateClaimRequest(operation, request) {
|
|
26
|
+
const required = REQUIRED_MEMBERS[operation];
|
|
27
|
+
if (!isPlainObject(request)) {
|
|
28
|
+
return [problem([], 'invalid-type', `The ${operation} request must be a JSON object.`)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const actual = Object.keys(request).sort().join(',');
|
|
32
|
+
const expected = [...required].sort().join(',');
|
|
33
|
+
if (actual !== expected) {
|
|
34
|
+
return [problem([], 'invalid-value', `The ${operation} request must have exactly: ${required.join(', ')}.`)];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const issues = [];
|
|
38
|
+
if (!NAMESPACE_ID.test(request.ledger_namespace)) {
|
|
39
|
+
issues.push(problem(['ledger_namespace'], 'invalid-value', 'Member ledger_namespace must match wbns_[a-f0-9]{32}.'));
|
|
40
|
+
}
|
|
41
|
+
if (!ITEM_ID.test(request.item_id)) {
|
|
42
|
+
issues.push(problem(['item_id'], 'invalid-value', 'Member item_id must match wb_[0-9A-HJKMNP-TV-Z]{26}.'));
|
|
43
|
+
}
|
|
44
|
+
if (required.includes('owner_id') && !isOwnerId(request.owner_id)) {
|
|
45
|
+
issues.push(problem(['owner_id'], 'invalid-value', 'Member owner_id must match [A-Za-z0-9][A-Za-z0-9._:/-]{0,127}.'));
|
|
46
|
+
}
|
|
47
|
+
if (required.includes('epoch') && !isCanonicalUint64(request.epoch, false)) {
|
|
48
|
+
issues.push(problem(['epoch'], 'invalid-value', 'Member epoch must be a canonical unsigned-64 decimal string.'));
|
|
49
|
+
}
|
|
50
|
+
if (required.includes('expected_expires_at') && !isStrictUtcInstant(request.expected_expires_at)) {
|
|
51
|
+
issues.push(problem(['expected_expires_at'], 'invalid-value', 'Member expected_expires_at must match YYYY-MM-DDTHH:MM:SS.mmmZ.'));
|
|
52
|
+
}
|
|
53
|
+
if (required.includes('lease_duration_ms') && !isLeaseDuration(request.lease_duration_ms)) {
|
|
54
|
+
issues.push(problem(['lease_duration_ms'], 'invalid-value', `Member lease_duration_ms must be an integer from 1 through ${MAX_LEASE_DURATION_MS}.`));
|
|
55
|
+
}
|
|
56
|
+
if (required.includes('expected')) {
|
|
57
|
+
issues.push(...expectedIssues(request.expected));
|
|
58
|
+
}
|
|
59
|
+
return issues;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function expectedIssues(expected) {
|
|
63
|
+
if (!isPlainObject(expected)) {
|
|
64
|
+
return [problem(['expected'], 'invalid-value', 'Member expected must be an object with exactly last_epoch and active.')];
|
|
65
|
+
}
|
|
66
|
+
const keys = Object.keys(expected).sort().join(',');
|
|
67
|
+
if (keys !== 'active,last_epoch') {
|
|
68
|
+
return [problem(['expected'], 'invalid-value', 'Member expected must have exactly last_epoch and active.')];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const issues = [];
|
|
72
|
+
if (!isCanonicalUint64(expected.last_epoch, true)) {
|
|
73
|
+
issues.push(problem(['expected', 'last_epoch'], 'invalid-value', 'Member expected.last_epoch must be a canonical unsigned-64 decimal string.'));
|
|
74
|
+
}
|
|
75
|
+
const active = expected.active;
|
|
76
|
+
if (active !== null) {
|
|
77
|
+
if (!isPlainObject(active)) {
|
|
78
|
+
issues.push(problem(['expected', 'active'], 'invalid-value', 'Member expected.active must be null or an object.'));
|
|
79
|
+
return issues;
|
|
80
|
+
}
|
|
81
|
+
const activeKeys = Object.keys(active).sort().join(',');
|
|
82
|
+
if (activeKeys !== 'epoch,expires_at,issued_at,owner_id') {
|
|
83
|
+
issues.push(problem(['expected', 'active'], 'invalid-value', 'Member expected.active must have exactly owner_id, epoch, issued_at, and expires_at.'));
|
|
84
|
+
return issues;
|
|
85
|
+
}
|
|
86
|
+
if (!isOwnerId(active.owner_id)) {
|
|
87
|
+
issues.push(problem(['expected', 'active', 'owner_id'], 'invalid-value', 'Member expected.active.owner_id must match [A-Za-z0-9][A-Za-z0-9._:/-]{0,127}.'));
|
|
88
|
+
}
|
|
89
|
+
if (!isCanonicalUint64(active.epoch, false)) {
|
|
90
|
+
issues.push(problem(['expected', 'active', 'epoch'], 'invalid-value', 'Member expected.active.epoch must be a canonical unsigned-64 decimal string.'));
|
|
91
|
+
}
|
|
92
|
+
if (!isStrictUtcInstant(active.issued_at)) {
|
|
93
|
+
issues.push(problem(['expected', 'active', 'issued_at'], 'invalid-value', 'Member expected.active.issued_at must match YYYY-MM-DDTHH:MM:SS.mmmZ.'));
|
|
94
|
+
}
|
|
95
|
+
if (!isStrictUtcInstant(active.expires_at)) {
|
|
96
|
+
issues.push(problem(['expected', 'active', 'expires_at'], 'invalid-value', 'Member expected.active.expires_at must match YYYY-MM-DDTHH:MM:SS.mmmZ.'));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return issues;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isOwnerId(value) {
|
|
103
|
+
return typeof value === 'string' && OWNER_ID.test(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isCanonicalUint64(value, allowZero) {
|
|
107
|
+
const pattern = allowZero ? /^(0|[1-9][0-9]{0,19})$/ : /^[1-9][0-9]{0,19}$/;
|
|
108
|
+
if (typeof value !== 'string' || !pattern.test(value)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return BigInt(value) <= MAX_EPOCH;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isStrictUtcInstant(value) {
|
|
119
|
+
const match = typeof value === 'string' ? UTC_INSTANT.exec(value) : null;
|
|
120
|
+
if (!match) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
const [, year, month, day, hour, minute, second, millis] = match.map(Number);
|
|
124
|
+
const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millis));
|
|
125
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
126
|
+
&& date.getUTCHours() === hour && date.getUTCMinutes() === minute && date.getUTCSeconds() === second
|
|
127
|
+
&& date.getUTCMilliseconds() === millis;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isLeaseDuration(value) {
|
|
131
|
+
return Number.isInteger(value) && value >= 1 && value <= MAX_LEASE_DURATION_MS;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isPlainObject(value) {
|
|
135
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function problem(location, code, message) {
|
|
139
|
+
return { path: pointer(location), code, message };
|
|
140
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { link, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const GIT_ENVIRONMENT = Object.fromEntries(
|
|
9
|
+
Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export async function resolveGitCommonDir(startDir) {
|
|
13
|
+
let current = path.resolve(startDir);
|
|
14
|
+
for (;;) {
|
|
15
|
+
const candidate = path.join(current, '.git');
|
|
16
|
+
let info = null;
|
|
17
|
+
try {
|
|
18
|
+
info = await stat(candidate);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
21
|
+
}
|
|
22
|
+
if (info?.isDirectory()) return candidate;
|
|
23
|
+
if (info?.isFile()) {
|
|
24
|
+
const text = await readFile(candidate, 'utf8');
|
|
25
|
+
const match = /^gitdir:\s*(.+)\s*$/m.exec(text);
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
const gitDir = path.resolve(current, match[1].trim());
|
|
28
|
+
try {
|
|
29
|
+
const commonText = await readFile(path.join(gitDir, 'commondir'), 'utf8');
|
|
30
|
+
return path.resolve(gitDir, commonText.trim());
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
33
|
+
return gitDir;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const parent = path.dirname(current);
|
|
37
|
+
if (parent === current) return null;
|
|
38
|
+
current = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function resolveVerifiedGitCommonDir(startDir) {
|
|
43
|
+
const discovered = await resolveGitCommonDir(startDir);
|
|
44
|
+
if (!discovered) return null;
|
|
45
|
+
try {
|
|
46
|
+
const { stdout } = await execFileAsync(
|
|
47
|
+
'git',
|
|
48
|
+
['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
49
|
+
{
|
|
50
|
+
cwd: path.resolve(startDir),
|
|
51
|
+
encoding: 'utf8',
|
|
52
|
+
env: GIT_ENVIRONMENT,
|
|
53
|
+
maxBuffer: 1024 * 1024,
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
const [reported, expected] = await Promise.all([
|
|
57
|
+
realpath(stdout.trim()),
|
|
58
|
+
realpath(discovered),
|
|
59
|
+
]);
|
|
60
|
+
return reported === expected ? discovered : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function claimStorePath(commonDir, namespace) {
|
|
67
|
+
return path.join(commonDir, 'wowbagger', `claims-${namespace}.json`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function emptyClaimState(namespace) {
|
|
71
|
+
return { schema_version: 1, ledger_namespace: namespace, clock_floor: null, claims: [] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function readClaimState(storePath, namespace) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(await readFile(storePath, 'utf8'));
|
|
77
|
+
if (parsed?.ledger_namespace !== namespace) return emptyClaimState(namespace);
|
|
78
|
+
return parsed;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error?.code === 'ENOENT') return emptyClaimState(namespace);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function writeClaimState(storePath, state) {
|
|
86
|
+
await mkdir(path.dirname(storePath), { recursive: true });
|
|
87
|
+
const ordered = {
|
|
88
|
+
...state,
|
|
89
|
+
claims: [...state.claims].sort((left, right) => (left.item_id < right.item_id ? -1 : left.item_id > right.item_id ? 1 : 0)),
|
|
90
|
+
};
|
|
91
|
+
const temporary = `${storePath}.tmp`;
|
|
92
|
+
const handle = await open(temporary, 'w');
|
|
93
|
+
try {
|
|
94
|
+
await handle.writeFile(`${JSON.stringify(ordered, null, 2)}\n`, 'utf8');
|
|
95
|
+
await handle.sync();
|
|
96
|
+
} finally {
|
|
97
|
+
await handle.close();
|
|
98
|
+
}
|
|
99
|
+
await rename(temporary, storePath);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function withClaimLock(storePath, fn) {
|
|
103
|
+
const directory = path.dirname(storePath);
|
|
104
|
+
await mkdir(directory, { recursive: true });
|
|
105
|
+
const lockPath = `${storePath}.lock`;
|
|
106
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
107
|
+
const owner = { version: 1, pid: process.pid, token: randomUUID() };
|
|
108
|
+
const candidatePath = `${lockPath}.${owner.token}.candidate`;
|
|
109
|
+
const candidate = await open(candidatePath, 'wx');
|
|
110
|
+
try {
|
|
111
|
+
await candidate.writeFile(`${JSON.stringify(owner)}\n`, 'utf8');
|
|
112
|
+
await candidate.sync();
|
|
113
|
+
} finally {
|
|
114
|
+
await candidate.close();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await acquireClaimLock(candidatePath, lockPath, recoveryPath);
|
|
119
|
+
try {
|
|
120
|
+
return await fn();
|
|
121
|
+
} finally {
|
|
122
|
+
if ((await readLockOwner(lockPath))?.token === owner.token) {
|
|
123
|
+
await rm(lockPath, { force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
await rm(candidatePath, { force: true });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function acquireClaimLock(candidatePath, lockPath, recoveryPath) {
|
|
132
|
+
for (;;) {
|
|
133
|
+
const recoveryOwner = await readLockOwner(recoveryPath);
|
|
134
|
+
if (recoveryOwner) {
|
|
135
|
+
if (!isDeadProcess(recoveryOwner.pid)) throw claimLockHeld();
|
|
136
|
+
await rm(recoveryPath, { force: true });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
await link(candidatePath, lockPath);
|
|
141
|
+
return;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await link(candidatePath, recoveryPath);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error?.code === 'EEXIST') continue;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const lockOwner = await readLockOwner(lockPath);
|
|
154
|
+
if (!lockOwner || !isDeadProcess(lockOwner.pid)) throw claimLockHeld();
|
|
155
|
+
await rm(lockPath, { force: true });
|
|
156
|
+
} finally {
|
|
157
|
+
await rm(recoveryPath, { force: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function readLockOwner(lockPath) {
|
|
163
|
+
let source;
|
|
164
|
+
try {
|
|
165
|
+
source = await readFile(lockPath, 'utf8');
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error?.code === 'ENOENT') return null;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const owner = JSON.parse(source);
|
|
172
|
+
if (owner?.version !== 1
|
|
173
|
+
|| !Number.isSafeInteger(owner.pid)
|
|
174
|
+
|| owner.pid <= 0
|
|
175
|
+
|| typeof owner.token !== 'string'
|
|
176
|
+
|| owner.token.length === 0) {
|
|
177
|
+
throw new Error('invalid lock owner');
|
|
178
|
+
}
|
|
179
|
+
return owner;
|
|
180
|
+
} catch {
|
|
181
|
+
throw claimLockHeld();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isDeadProcess(pid) {
|
|
186
|
+
try {
|
|
187
|
+
process.kill(pid, 0);
|
|
188
|
+
return false;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return error?.code === 'ESRCH';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function claimLockHeld() {
|
|
195
|
+
const error = new Error('claim store lock is held');
|
|
196
|
+
error.code = 'CLAIM_LOCK_HELD';
|
|
197
|
+
return error;
|
|
198
|
+
}
|