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,60 @@
|
|
|
1
|
+
// Small schema-checking primitives shared by describe.js and core-probe.js.
|
|
2
|
+
// Both files validate several exact-shape JSON objects (the describe
|
|
3
|
+
// request, the static manifest, the dynamic describe result, and the core
|
|
4
|
+
// capabilities probe) and need identical notions of "safe integer",
|
|
5
|
+
// "exact member set", and "deep JSON equality" so the checks agree with
|
|
6
|
+
// each other.
|
|
7
|
+
|
|
8
|
+
function isPlainObject(value) {
|
|
9
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// `value` has exactly the `required` members, plus zero or more of the
|
|
13
|
+
// `optional` members, and nothing else.
|
|
14
|
+
export function hasExactMembers(value, required, optional = []) {
|
|
15
|
+
if (!isPlainObject(value)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const allowed = new Set([...required, ...optional]);
|
|
19
|
+
const present = Object.keys(value);
|
|
20
|
+
return present.every((key) => allowed.has(key)) && required.every((key) => present.includes(key));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isPositiveSafeInteger(value) {
|
|
24
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isNonNegativeSafeInteger(value) {
|
|
28
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isNonEmptyString(value) {
|
|
32
|
+
return typeof value === 'string' && value.length > 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isAllBoolean(value) {
|
|
36
|
+
return Object.values(value).every((member) => typeof member === 'boolean');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Recursively sorts object members by key and leaves everything else alone.
|
|
40
|
+
// Array element order is deliberately untouched: two of sameJson's three call
|
|
41
|
+
// sites compare arrays where order is significant (`adapter_contract_versions`
|
|
42
|
+
// and `trusted_approval.sources`), so sorting elements would erase a real
|
|
43
|
+
// difference. Object.fromEntries defines rather than assigns, so a member
|
|
44
|
+
// named `__proto__` survives the rebuild instead of becoming a prototype.
|
|
45
|
+
function canonicalize(value) {
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
return value.map(canonicalize);
|
|
48
|
+
}
|
|
49
|
+
if (value !== null && typeof value === 'object') {
|
|
50
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Deep JSON equality that treats member order as insignificant, per RFC 8259.
|
|
56
|
+
// JSON.parse preserves the source member order, so two wire values carrying
|
|
57
|
+
// the same map under different key orders would otherwise be falsely refused.
|
|
58
|
+
export function sameJson(left, right) {
|
|
59
|
+
return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
|
|
60
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function resolveWorkClaimCapability({ gitCommonDir, namespace = null }) {
|
|
2
|
+
if (!gitCommonDir) {
|
|
3
|
+
return {
|
|
4
|
+
supported: false,
|
|
5
|
+
api_version: 1,
|
|
6
|
+
mode: 'advisory',
|
|
7
|
+
claim_protected_publication: false,
|
|
8
|
+
fencing_enforced_at: 'none',
|
|
9
|
+
safe_exclusive_dispatch: false,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
if (!namespace) {
|
|
13
|
+
return {
|
|
14
|
+
supported: true,
|
|
15
|
+
api_version: 1,
|
|
16
|
+
mode: 'advisory',
|
|
17
|
+
claim_protected_publication: false,
|
|
18
|
+
fencing_enforced_at: 'none',
|
|
19
|
+
safe_exclusive_dispatch: false,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
supported: true,
|
|
24
|
+
api_version: 1,
|
|
25
|
+
mode: 'merge-coordinated',
|
|
26
|
+
claim_protected_publication: true,
|
|
27
|
+
fencing_enforced_at: 'git-history-reconciliation',
|
|
28
|
+
safe_exclusive_dispatch: false,
|
|
29
|
+
write_paths: {
|
|
30
|
+
alternate: 'none',
|
|
31
|
+
claimed_publication_v1: 'git-journal-fence',
|
|
32
|
+
legacy_create_v1: 'reject-claimed-id',
|
|
33
|
+
legacy_transition_v1: 'reject-active-claim',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveClaimBackend({ gitCommonDir, namespace = null }) {
|
|
39
|
+
if (!gitCommonDir || !namespace) {
|
|
40
|
+
return {
|
|
41
|
+
name: 'local-filesystem',
|
|
42
|
+
coordination_scope: coordinationScope({ gitCommonDir }),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
name: 'local-filesystem-git-journal',
|
|
47
|
+
coordination_scope: 'shared-git-common-dir-serialized-journal',
|
|
48
|
+
ledger_binding: { mode: 'explicit-allowlist', namespaces: [namespace] },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function coordinationScope({ gitCommonDir }) {
|
|
53
|
+
return gitCommonDir ? 'shared-git-directory-cooperative-writers' : 'same-working-copy-cooperative-writers';
|
|
54
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
|
|
2
|
+
import { claimJournalPath, replayClaimJournal } from './claim-journal.js';
|
|
3
|
+
import { resolveWorkClaimCapability } from './claim-capabilities.js';
|
|
4
|
+
import { readBack } from './claim-operations.js';
|
|
5
|
+
import { reconcileClaimJournal } from './claim-publication.js';
|
|
6
|
+
import { claimStorePath, resolveVerifiedGitCommonDir, withClaimLock } from './claim-store.js';
|
|
7
|
+
import { readNamespace } from './namespace.js';
|
|
8
|
+
|
|
9
|
+
export async function withLegacyMutationFence(ledgerDirectory, itemId, command, write) {
|
|
10
|
+
const gitCommonDir = await resolveVerifiedGitCommonDir(ledgerDirectory);
|
|
11
|
+
const namespace = gitCommonDir ? await readNamespace(ledgerDirectory) : null;
|
|
12
|
+
const capability = resolveWorkClaimCapability({ gitCommonDir, namespace });
|
|
13
|
+
if (!capability.claim_protected_publication) return write();
|
|
14
|
+
|
|
15
|
+
const storePath = claimStorePath(gitCommonDir, namespace);
|
|
16
|
+
const journalPath = claimJournalPath(gitCommonDir, namespace);
|
|
17
|
+
try {
|
|
18
|
+
return await withClaimLock(storePath, async () => {
|
|
19
|
+
const replayed = await replayClaimJournal(journalPath, namespace);
|
|
20
|
+
const reconciled = await reconcileClaimJournal({
|
|
21
|
+
ledgerDirectory,
|
|
22
|
+
gitCommonDir,
|
|
23
|
+
namespace,
|
|
24
|
+
replayed,
|
|
25
|
+
physicalNow: new Date().toISOString(),
|
|
26
|
+
});
|
|
27
|
+
if (reconciled.unsafe) {
|
|
28
|
+
return claimStoreUnavailable(command, 'publication-reconciliation-required', {
|
|
29
|
+
findings: reconciled.findings,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const observedAt = reconciled.observedAt;
|
|
33
|
+
const record = reconciled.state.claims.find((entry) => entry.item_id === itemId)
|
|
34
|
+
?? { item_id: itemId, last_epoch: '0', active: null };
|
|
35
|
+
const mustRefuse = command === 'create-v1'
|
|
36
|
+
? record.last_epoch !== '0'
|
|
37
|
+
: record.active !== null && observedAt < record.active.expires_at;
|
|
38
|
+
if (!mustRefuse) return write();
|
|
39
|
+
return legacyRefusal(command, namespace, itemId, observedAt, record);
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return claimStoreUnavailable(command, error?.code === 'CLAIM_LOCK_HELD'
|
|
43
|
+
? 'claim-store-locked'
|
|
44
|
+
: 'claim-store-unreadable');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function legacyRefusal(command, namespace, itemId, observedAt, record) {
|
|
49
|
+
const create = command === 'create-v1';
|
|
50
|
+
return {
|
|
51
|
+
exit: 4,
|
|
52
|
+
stdout: {
|
|
53
|
+
ok: false,
|
|
54
|
+
namespace: 'ledger-mutation',
|
|
55
|
+
command,
|
|
56
|
+
contract_version: 1,
|
|
57
|
+
state: 'unchanged',
|
|
58
|
+
error: {
|
|
59
|
+
code: create ? 'claimed-item-write-refused' : 'active-claim-write-refused',
|
|
60
|
+
message: create
|
|
61
|
+
? 'Legacy create cannot write an item identity with claim history.'
|
|
62
|
+
: 'Legacy transition cannot write an item with an active claim.',
|
|
63
|
+
details: readBack(namespace, itemId, observedAt, record),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function claimStoreUnavailable(command, reason, details = {}) {
|
|
70
|
+
return {
|
|
71
|
+
exit: 6,
|
|
72
|
+
stdout: {
|
|
73
|
+
ok: false,
|
|
74
|
+
namespace: 'ledger-mutation',
|
|
75
|
+
command,
|
|
76
|
+
contract_version: 1,
|
|
77
|
+
state: 'unchanged',
|
|
78
|
+
error: {
|
|
79
|
+
code: 'claim-store-unavailable',
|
|
80
|
+
message: 'The durable claim store is unavailable.',
|
|
81
|
+
details: { reason, ...details },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { mkdir, open, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { validateClaimRequest } from './claim-request.js';
|
|
5
|
+
|
|
6
|
+
import { advanceClockFloor, claimAcquire, claimRead, claimRelease, claimRenew } from './claim-operations.js';
|
|
7
|
+
import { emptyClaimState } from './claim-store.js';
|
|
8
|
+
|
|
9
|
+
const MAX_JOURNAL_ENTRIES = 65536;
|
|
10
|
+
const MAX_JOURNAL_BYTES = 8388608;
|
|
11
|
+
const MAX_RECONCILE_LOG_BYTES = MAX_JOURNAL_BYTES + 1024;
|
|
12
|
+
const JOURNAL_ENTRY_TYPES = new Set([
|
|
13
|
+
'claim',
|
|
14
|
+
'clock',
|
|
15
|
+
'publish-final',
|
|
16
|
+
'publish-finalization',
|
|
17
|
+
'publish-intent',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export function claimJournalPath(commonDir, namespace) {
|
|
21
|
+
return path.join(commonDir, 'wowbagger', namespace, 'journal.ndjson');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function claimReconcileLogPath(repoRoot, namespace) {
|
|
25
|
+
return path.join(repoRoot, 'wowbagger', `reconcile-${namespace}.md`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function appendClaimEntry(journalPath, entry) {
|
|
29
|
+
const entries = await readJournalEntries(journalPath);
|
|
30
|
+
if (entries.length >= MAX_JOURNAL_ENTRIES) throw journalCapacityExceeded();
|
|
31
|
+
const persisted = { seq: entries.length + 1, ...entry };
|
|
32
|
+
const line = `${JSON.stringify(persisted)}\n`;
|
|
33
|
+
const existingBytes = await fileSize(journalPath);
|
|
34
|
+
if (existingBytes + Buffer.byteLength(line) > MAX_JOURNAL_BYTES) throw journalCapacityExceeded();
|
|
35
|
+
await ensureDurableJournal(journalPath);
|
|
36
|
+
const handle = await open(journalPath, 'a');
|
|
37
|
+
try {
|
|
38
|
+
await handle.writeFile(line, 'utf8');
|
|
39
|
+
await handle.sync();
|
|
40
|
+
} finally {
|
|
41
|
+
await handle.close();
|
|
42
|
+
}
|
|
43
|
+
return persisted;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function replayClaimJournal(journalPath, namespace) {
|
|
47
|
+
const entries = await readJournalEntries(journalPath, namespace);
|
|
48
|
+
let state = emptyClaimState(namespace);
|
|
49
|
+
const operations = { acquire: claimAcquire, read: claimRead, release: claimRelease, renew: claimRenew };
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (entry.type === 'clock') {
|
|
52
|
+
advanceClockFloor(state, entry.floor);
|
|
53
|
+
} else if (entry.type === 'claim' && Object.hasOwn(operations, entry.command)) {
|
|
54
|
+
state = operations[entry.command](state, entry.request, entry.physical_now).state;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { state, entries };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function writeReconcileLog(logPath, namespace, entries) {
|
|
61
|
+
const mergeableEntries = entries.filter((entry) => entry.type !== 'publish-finalization');
|
|
62
|
+
const content = [
|
|
63
|
+
`# Wowbagger reconciliation log \`${namespace}\``,
|
|
64
|
+
'',
|
|
65
|
+
'Derived from the authoritative common-directory journal. Preserve sequence order when merging.',
|
|
66
|
+
'',
|
|
67
|
+
'```jsonl',
|
|
68
|
+
...mergeableEntries.map((entry) => JSON.stringify(entry)),
|
|
69
|
+
'```',
|
|
70
|
+
'',
|
|
71
|
+
].join('\n');
|
|
72
|
+
if (Buffer.byteLength(content) > MAX_RECONCILE_LOG_BYTES) throw journalCapacityExceeded();
|
|
73
|
+
await mkdir(path.dirname(logPath), { recursive: true });
|
|
74
|
+
const handle = await open(logPath,
|
|
75
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW);
|
|
76
|
+
try {
|
|
77
|
+
await handle.writeFile(content, 'utf8');
|
|
78
|
+
await handle.sync();
|
|
79
|
+
} finally {
|
|
80
|
+
await handle.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readJournalEntries(journalPath, namespace = null) {
|
|
85
|
+
let source;
|
|
86
|
+
try {
|
|
87
|
+
const info = await stat(journalPath);
|
|
88
|
+
if (info.size > MAX_JOURNAL_BYTES) throw journalCapacityExceeded();
|
|
89
|
+
source = await readFile(journalPath, 'utf8');
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code === 'ENOENT') return [];
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
const lines = source.split('\n').filter(Boolean);
|
|
95
|
+
if (lines.length > MAX_JOURNAL_ENTRIES) throw journalCapacityExceeded();
|
|
96
|
+
const entries = lines.map((line) => JSON.parse(line));
|
|
97
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
98
|
+
if (!Number.isSafeInteger(entries[index]?.seq) || entries[index].seq !== index + 1) {
|
|
99
|
+
throw journalInvalid('non-contiguous-sequence');
|
|
100
|
+
}
|
|
101
|
+
if (!JOURNAL_ENTRY_TYPES.has(entries[index].type)) {
|
|
102
|
+
throw journalInvalid('unknown-entry-type');
|
|
103
|
+
}
|
|
104
|
+
if (!validJournalEntry(entries[index], namespace)) {
|
|
105
|
+
throw journalInvalid('invalid-entry');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return entries;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function ensureDurableJournal(journalPath) {
|
|
112
|
+
const journalDirectory = path.dirname(journalPath);
|
|
113
|
+
await mkdir(journalDirectory, { recursive: true });
|
|
114
|
+
const directories = [
|
|
115
|
+
path.dirname(path.dirname(journalDirectory)),
|
|
116
|
+
path.dirname(journalDirectory),
|
|
117
|
+
journalDirectory,
|
|
118
|
+
];
|
|
119
|
+
for (const directory of new Set(directories)) {
|
|
120
|
+
await syncDirectory(directory);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let handle;
|
|
124
|
+
try {
|
|
125
|
+
handle = await open(journalPath, 'ax');
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error?.code === 'EEXIST') return;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await handle.sync();
|
|
132
|
+
} finally {
|
|
133
|
+
await handle.close();
|
|
134
|
+
}
|
|
135
|
+
await syncDirectory(journalDirectory);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function syncDirectory(directory) {
|
|
139
|
+
const handle = await open(directory, 'r');
|
|
140
|
+
try {
|
|
141
|
+
await handle.sync();
|
|
142
|
+
} finally {
|
|
143
|
+
await handle.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function fileSize(file) {
|
|
148
|
+
try {
|
|
149
|
+
return (await stat(file)).size;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error?.code === 'ENOENT') return 0;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function journalCapacityExceeded() {
|
|
157
|
+
const error = new Error('claim journal capacity exceeded');
|
|
158
|
+
error.code = 'CLAIM_JOURNAL_CAPACITY';
|
|
159
|
+
error.reason = 'journal-capacity-exceeded';
|
|
160
|
+
return error;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function journalInvalid(reason) {
|
|
164
|
+
const error = new Error('claim journal is invalid');
|
|
165
|
+
error.code = 'CLAIM_JOURNAL_INVALID';
|
|
166
|
+
error.reason = reason;
|
|
167
|
+
return error;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validJournalEntry(entry, namespace) {
|
|
171
|
+
if (entry.type === 'clock') {
|
|
172
|
+
return typeof entry.now === 'string' && typeof entry.floor === 'string';
|
|
173
|
+
}
|
|
174
|
+
if (entry.type === 'claim') {
|
|
175
|
+
return ['acquire', 'read', 'release', 'renew'].includes(entry.command)
|
|
176
|
+
&& typeof entry.physical_now === 'string'
|
|
177
|
+
&& validateClaimRequest(entry.command, entry.request).length === 0
|
|
178
|
+
&& (namespace === null || entry.request.ledger_namespace === namespace);
|
|
179
|
+
}
|
|
180
|
+
if (entry.type === 'publish-intent') {
|
|
181
|
+
return typeof entry.operation_id === 'string'
|
|
182
|
+
&& typeof entry.operation_digest === 'string'
|
|
183
|
+
&& typeof entry.item_id === 'string'
|
|
184
|
+
&& typeof entry.expected_revision === 'string'
|
|
185
|
+
&& typeof entry.candidate_sha256 === 'string'
|
|
186
|
+
&& isRecord(entry.fence)
|
|
187
|
+
&& (namespace === null || entry.fence.ledger_namespace === namespace)
|
|
188
|
+
&& entry.fence.item_id === entry.item_id;
|
|
189
|
+
}
|
|
190
|
+
if (entry.type === 'publish-final') {
|
|
191
|
+
return typeof entry.operation_id === 'string'
|
|
192
|
+
&& typeof entry.operation_digest === 'string'
|
|
193
|
+
&& typeof entry.ledger_namespace === 'string'
|
|
194
|
+
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
195
|
+
&& typeof entry.item_id === 'string'
|
|
196
|
+
&& validPublicationOutcome(entry);
|
|
197
|
+
}
|
|
198
|
+
return typeof entry.operation_id === 'string'
|
|
199
|
+
&& typeof entry.item_id === 'string'
|
|
200
|
+
&& typeof entry.committed_revision === 'string'
|
|
201
|
+
&& typeof entry.git_commit === 'string';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isRecord(value) {
|
|
205
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function validPublicationOutcome(entry) {
|
|
209
|
+
const outcome = entry.outcome;
|
|
210
|
+
if (!isRecord(outcome) || !Number.isInteger(outcome.exit) || !isRecord(outcome.stdout)) return false;
|
|
211
|
+
const stdout = outcome.stdout;
|
|
212
|
+
if (
|
|
213
|
+
stdout.namespace !== 'ledger-publication'
|
|
214
|
+
|| stdout.command !== 'publish-claimed'
|
|
215
|
+
|| stdout.contract_version !== 1
|
|
216
|
+
|| stdout.operation_id !== entry.operation_id
|
|
217
|
+
|| !['committed', 'unchanged', 'unknown'].includes(stdout.state)
|
|
218
|
+
) return false;
|
|
219
|
+
if (stdout.ok === true) {
|
|
220
|
+
return outcome.exit === 0
|
|
221
|
+
&& stdout.state === 'committed'
|
|
222
|
+
&& isRecord(stdout.result)
|
|
223
|
+
&& stdout.result.ledger_namespace === entry.ledger_namespace
|
|
224
|
+
&& stdout.result.item_id === entry.item_id
|
|
225
|
+
&& typeof stdout.result.committed_revision === 'string'
|
|
226
|
+
&& isRecord(stdout.result.claim_fence)
|
|
227
|
+
&& stdout.result.claim_fence.ledger_namespace === entry.ledger_namespace
|
|
228
|
+
&& stdout.result.claim_fence.item_id === entry.item_id;
|
|
229
|
+
}
|
|
230
|
+
if (stdout.ok !== false || !isRecord(stdout.error)) return false;
|
|
231
|
+
const details = stdout.error.details;
|
|
232
|
+
return !isRecord(details)
|
|
233
|
+
|| ((!Object.hasOwn(details, 'operation_id') || details.operation_id === entry.operation_id)
|
|
234
|
+
&& (!Object.hasOwn(details, 'ledger_namespace') || details.ledger_namespace === entry.ledger_namespace)
|
|
235
|
+
&& (!Object.hasOwn(details, 'item_id') || details.item_id === entry.item_id));
|
|
236
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/claim-operations.js
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const MAX_EPOCH = 18446744073709551615n;
|
|
5
|
+
|
|
6
|
+
export function advanceClockFloor(state, physicalNow) {
|
|
7
|
+
const floor = state.clock_floor;
|
|
8
|
+
const effective = floor === null || physicalNow > floor ? physicalNow : floor;
|
|
9
|
+
state.clock_floor = effective;
|
|
10
|
+
return effective;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function findOrCreateClaim(state, itemId) {
|
|
14
|
+
let record = state.claims.find((entry) => entry.item_id === itemId);
|
|
15
|
+
if (!record) {
|
|
16
|
+
record = { item_id: itemId, last_epoch: '0', active: null };
|
|
17
|
+
state.claims.push(record);
|
|
18
|
+
}
|
|
19
|
+
return record;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readBack(namespace, itemId, observedAt, record) {
|
|
23
|
+
return {
|
|
24
|
+
ledger_namespace: namespace,
|
|
25
|
+
item_id: itemId,
|
|
26
|
+
observed_at: observedAt,
|
|
27
|
+
last_epoch: record.last_epoch,
|
|
28
|
+
active: record.active === null ? null : { ...record.active },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function success(command, request, observedAt, record, extra) {
|
|
33
|
+
return {
|
|
34
|
+
exit: 0,
|
|
35
|
+
stdout: {
|
|
36
|
+
ok: true,
|
|
37
|
+
namespace: 'work-claim',
|
|
38
|
+
command,
|
|
39
|
+
contract_version: 1,
|
|
40
|
+
state: 'committed',
|
|
41
|
+
result: { ...extra, read_back: readBack(request.ledger_namespace, request.item_id, observedAt, record) },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function claimError(command, code, message, request, observedAt, record, exit = 4) {
|
|
47
|
+
return {
|
|
48
|
+
exit,
|
|
49
|
+
stdout: {
|
|
50
|
+
ok: false,
|
|
51
|
+
namespace: 'work-claim',
|
|
52
|
+
command,
|
|
53
|
+
contract_version: 1,
|
|
54
|
+
state: 'unchanged',
|
|
55
|
+
error: {
|
|
56
|
+
code,
|
|
57
|
+
message,
|
|
58
|
+
details: readBack(request.ledger_namespace, request.item_id, observedAt, record),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function claimRead(state, request, physicalNow) {
|
|
65
|
+
const next = structuredClone(state);
|
|
66
|
+
const observedAt = advanceClockFloor(next, physicalNow);
|
|
67
|
+
const record = findOrCreateClaim(next, request.item_id);
|
|
68
|
+
return { state: next, envelope: success('read', request, observedAt, record, {}) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function addMilliseconds(instant, milliseconds) {
|
|
72
|
+
return new Date(Date.parse(instant) + milliseconds).toISOString();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function claimAcquire(state, request, physicalNow) {
|
|
76
|
+
const next = structuredClone(state);
|
|
77
|
+
const observedAt = advanceClockFloor(next, physicalNow);
|
|
78
|
+
const record = findOrCreateClaim(next, request.item_id);
|
|
79
|
+
const observed = { last_epoch: record.last_epoch, active: record.active };
|
|
80
|
+
if (!isDeepStrictEqual(observed, { last_epoch: request.expected.last_epoch, active: request.expected.active })) {
|
|
81
|
+
return { state: next, envelope: claimError('acquire', 'claim-conflict',
|
|
82
|
+
'The observed claim state no longer matches this request.', request, observedAt, record) };
|
|
83
|
+
}
|
|
84
|
+
if (record.active !== null && observedAt < record.active.expires_at) {
|
|
85
|
+
return { state: next, envelope: claimError('acquire', 'claim-held',
|
|
86
|
+
'The item has an unexpired active claim.', request, observedAt, record) };
|
|
87
|
+
}
|
|
88
|
+
if (BigInt(record.last_epoch) >= MAX_EPOCH) {
|
|
89
|
+
return { state: next, envelope: claimError('acquire', 'epoch-exhausted',
|
|
90
|
+
'The epoch high-water mark is exhausted.', request, observedAt, record, 6) };
|
|
91
|
+
}
|
|
92
|
+
const epoch = (BigInt(record.last_epoch) + 1n).toString();
|
|
93
|
+
record.last_epoch = epoch;
|
|
94
|
+
record.active = {
|
|
95
|
+
owner_id: request.owner_id,
|
|
96
|
+
epoch,
|
|
97
|
+
issued_at: observedAt,
|
|
98
|
+
expires_at: addMilliseconds(observedAt, request.lease_duration_ms),
|
|
99
|
+
};
|
|
100
|
+
return { state: next, envelope: success('acquire', request, observedAt, record, { claim: { ...record.active } }) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function tupleMatches(active, request) {
|
|
104
|
+
return active !== null
|
|
105
|
+
&& active.owner_id === request.owner_id
|
|
106
|
+
&& active.epoch === request.epoch
|
|
107
|
+
&& active.expires_at === request.expected_expires_at;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renewOrRelease(command, state, request, physicalNow, apply) {
|
|
111
|
+
const next = structuredClone(state);
|
|
112
|
+
const observedAt = advanceClockFloor(next, physicalNow);
|
|
113
|
+
const record = findOrCreateClaim(next, request.item_id);
|
|
114
|
+
if (!tupleMatches(record.active, request)) {
|
|
115
|
+
return { state: next, envelope: claimError(command, 'claim-conflict',
|
|
116
|
+
'The active claim tuple no longer matches this request.', request, observedAt, record) };
|
|
117
|
+
}
|
|
118
|
+
if (observedAt >= record.active.expires_at) {
|
|
119
|
+
return { state: next, envelope: claimError(command, 'claim-expired',
|
|
120
|
+
'The matching claim has expired.', request, observedAt, record) };
|
|
121
|
+
}
|
|
122
|
+
return apply(next, record, observedAt);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function claimRenew(state, request, physicalNow) {
|
|
126
|
+
return renewOrRelease('renew', state, request, physicalNow, (next, record, observedAt) => {
|
|
127
|
+
record.active = { ...record.active, expires_at: addMilliseconds(observedAt, request.lease_duration_ms) };
|
|
128
|
+
return { state: next, envelope: success('renew', request, observedAt, record, { claim: { ...record.active } }) };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function claimRelease(state, request, physicalNow) {
|
|
133
|
+
return renewOrRelease('release', state, request, physicalNow, (next, record, observedAt) => {
|
|
134
|
+
const released = { ...record.active };
|
|
135
|
+
record.active = null;
|
|
136
|
+
return { state: next, envelope: success('release', request, observedAt, record, { released_claim: released }) };
|
|
137
|
+
});
|
|
138
|
+
}
|