wowbagger 0.1.0-alpha.2 → 0.1.0-alpha.4
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 +29 -0
- package/README.md +74 -35
- package/adapters/claude-code/wowbagger-adapter.json +1 -1
- package/docs/adapter-contract.md +1280 -0
- package/docs/work-claim-contract.md +21 -4
- package/package.json +3 -1
- package/skills/wowbagger/SKILL.md +37 -5
- package/src/adapter/entrypoint-main.js +19 -5
- package/src/claim-coordinator.js +113 -7
- package/src/claim-journal.js +45 -2
- package/src/claim-publication.js +121 -59
- package/src/claim-store.js +20 -8
- package/src/cli.js +118 -11
- package/src/mutation.js +151 -32
- package/src/ready.js +64 -38
- package/src/report-html.js +198 -0
- package/src/report-markdown.js +171 -0
- package/src/report.js +396 -0
|
@@ -263,6 +263,10 @@ It returns `result.read_back` with exactly `ledger_namespace`, `item_id`,
|
|
|
263
263
|
reservation. It is the recovery operation after a lost claim response: the
|
|
264
264
|
caller reads the tuple before retrying an acquire, renew, or release.
|
|
265
265
|
|
|
266
|
+
`work-claim.read`, capability discovery, and `ledger-publication.read` are
|
|
267
|
+
observational. They MUST NOT take the namespace write lock, advance the durable
|
|
268
|
+
clock floor, reconcile publications, or change coordinator files.
|
|
269
|
+
|
|
266
270
|
### Acquire and takeover
|
|
267
271
|
|
|
268
272
|
`work-claim.acquire` accepts exactly:
|
|
@@ -467,10 +471,11 @@ journal; that log is a derived audit artifact, not authority.
|
|
|
467
471
|
|
|
468
472
|
A clean verification returns exit 0 and `state: "committed"`. Findings named
|
|
469
473
|
`pending-intent-resolved` are clean recovery. Any
|
|
470
|
-
`
|
|
471
|
-
`stale-write-detected` finding returns exit 6 and
|
|
472
|
-
MUST stop publication work and inspect those
|
|
473
|
-
MUST NOT duplicate a publication
|
|
474
|
+
`legacy-mutation-outcome-unknown`, `publication-outcome-unknown`,
|
|
475
|
+
`revision-regression`, or `stale-write-detected` finding returns exit 6 and
|
|
476
|
+
`state: "unknown"`. A caller MUST stop publication work and inspect those
|
|
477
|
+
findings. Repeating verification MUST NOT duplicate a publication
|
|
478
|
+
finalization.
|
|
474
479
|
|
|
475
480
|
The top-level `state: "committed"` describes durable reconciliation state, not
|
|
476
481
|
Git finalization of every successful publication. A caller MUST gate Git
|
|
@@ -498,6 +503,18 @@ create MUST reject any item identity whose tuple has claim history with exit 4
|
|
|
498
503
|
high-water mark. Both checks persist authoritative decision time before their
|
|
499
504
|
response.
|
|
500
505
|
|
|
506
|
+
Before a merge-coordinated backend permits a legacy transition or patch to
|
|
507
|
+
write bytes, it MUST fsync a `legacy-mutation-intent` with the expected and
|
|
508
|
+
candidate revisions. Under the same namespace lock, it MUST reserve journal
|
|
509
|
+
capacity for that intent, one recovery clock entry, and one terminal entry. A
|
|
510
|
+
committed write appends `legacy-mutation`; an unchanged write appends
|
|
511
|
+
`legacy-mutation-abort`. Reconciliation resolves a pending intent to the
|
|
512
|
+
committed terminal when the candidate revision is present, or to the abort
|
|
513
|
+
terminal when the expected revision remains. Any third revision produces
|
|
514
|
+
`legacy-mutation-outcome-unknown`. The latest committed terminal is the
|
|
515
|
+
authorized expected revision. A later unrecorded revision remains a stale
|
|
516
|
+
write.
|
|
517
|
+
|
|
501
518
|
An implementation may instead route a legacy write through `publish-claimed`,
|
|
502
519
|
but it cannot silently omit a fence. Administrative repair, bulk import,
|
|
503
520
|
plugins, direct database writes, and filesystem writers count as alternate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wowbagger",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.4",
|
|
4
4
|
"description": "Plain-Markdown, Git-native work ledger for coordinating agents — validate, ready-select, and mutate a task ledger from the CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"src",
|
|
37
37
|
"skills",
|
|
38
38
|
"adapters",
|
|
39
|
+
"docs/adapter-contract.md",
|
|
39
40
|
"docs/mutation-contract.md",
|
|
40
41
|
"docs/work-claim-contract.md",
|
|
41
42
|
"scripts/migrate-schema-2.js",
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
],
|
|
46
47
|
"scripts": {
|
|
47
48
|
"check": "npm test && git diff --check && git diff --cached --check",
|
|
49
|
+
"report": "node bin/wowbagger.js report --ledger ledger --json",
|
|
48
50
|
"test": "node --test test/*.test.js",
|
|
49
51
|
"prepublishOnly": "node bin/wowbagger.js validate --ledger ledger --json >/dev/null && node --check bin/wowbagger.js && node scripts/verify-release-tag.js"
|
|
50
52
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wowbagger
|
|
3
|
-
description: Use when coordinating work through a wowbagger ledger — reading the ready queue, inspecting or filing an item, transitioning one through its lifecycle, taking a work claim, or publishing claimed work. Triggers on "ready queue", "what should I work on", "file a ledger item", "close this item", "claim this work", "publish claimed work", or any mention of a wowbagger ledger. Not for general backlog talk where no wowbagger ledger exists.
|
|
3
|
+
description: Use when coordinating work through a wowbagger ledger — reading the ready queue, inspecting or filing an item, generating an HTML ledger report, transitioning one through its lifecycle, taking a work claim, or publishing claimed work. Triggers on "ready queue", "ledger report", "backlog report", "what should I work on", "file a ledger item", "close this item", "claim this work", "publish claimed work", or any mention of a wowbagger ledger. Not for general backlog talk where no wowbagger ledger exists.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Wowbagger
|
|
@@ -15,23 +15,29 @@ This skill does **not** bundle the wowbagger core. It drives an installed one,
|
|
|
15
15
|
so a version mismatch is detectable rather than silent.
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
+
wowbagger --version
|
|
18
19
|
wowbagger capabilities --json
|
|
19
20
|
```
|
|
20
21
|
|
|
21
|
-
Read the
|
|
22
|
-
requires
|
|
22
|
+
Read the plain distribution version from the first command and the top-level
|
|
23
|
+
`contract_version` from the second. **This skill requires distribution version
|
|
24
|
+
`0.1.0-alpha.4` and core `contract_version: 2`.**
|
|
23
25
|
|
|
24
26
|
- Command not found → the core is not installed. Tell the user, point them at
|
|
25
27
|
<https://github.com/lstutzman/wowbagger>, and stop. Do not fall back to
|
|
26
28
|
editing ledger files by hand — hand-edits bypass validation and atomic
|
|
27
29
|
publication, which is the whole point of the tool.
|
|
30
|
+
- If the distribution version is missing or is different, stop and report the
|
|
31
|
+
installed and required versions. An older core can share contract version 2
|
|
32
|
+
while still lacking behavior this skill requires. Do not guess from the
|
|
33
|
+
contract version alone.
|
|
28
34
|
- `contract_version` is anything other than `2` → stop and say so plainly. A
|
|
29
35
|
core reporting `1` predates schema version 2, where `depends_on` records
|
|
30
36
|
declared prerequisites rather than only live blockers; an older or newer core
|
|
31
37
|
may have changed the request or response shape. Do not guess.
|
|
32
38
|
|
|
33
|
-
Run
|
|
34
|
-
command.
|
|
39
|
+
Run both commands once per session before the first ledger command, not before
|
|
40
|
+
every command.
|
|
35
41
|
|
|
36
42
|
## Reading
|
|
37
43
|
|
|
@@ -55,6 +61,32 @@ wowbagger capabilities --json
|
|
|
55
61
|
revision is what `transition` compares against, so inspect immediately before
|
|
56
62
|
transitioning.
|
|
57
63
|
|
|
64
|
+
## Generating an HTML report
|
|
65
|
+
|
|
66
|
+
The report is read-only derived output. Its configuration is
|
|
67
|
+
`<ledger>/.wowbagger/report.json`.
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
wowbagger report --ledger <dir> --as-of <YYYY-MM-DD> --json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Use today's date for `--as-of`. Use `--out <file>` only when the caller needs to
|
|
74
|
+
override the configured output. The resolved output must stay outside the
|
|
75
|
+
ledger. Do not use an npm script as a machine protocol; invoke `wowbagger`
|
|
76
|
+
directly so standard output contains exactly one compact JSON object.
|
|
77
|
+
|
|
78
|
+
On success, require exit `0`, `ok: true`, `command: "report"`,
|
|
79
|
+
`contract_version: 2`, `result.report_version: 1`, and the requested
|
|
80
|
+
`result.as_of`. Read the generated file from the absolute `result.output`.
|
|
81
|
+
On failure, require `ok: false` and inspect `error.code`; do not treat an
|
|
82
|
+
existing output as fresh because failed publication preserves the prior report.
|
|
83
|
+
|
|
84
|
+
The generated HTML does not authorize transitions, claims, or parallel work.
|
|
85
|
+
It has no live ledger revision. Its readiness state is the canonical projection
|
|
86
|
+
at generation time, but the file is only a static view. Area-diverse batches are
|
|
87
|
+
scheduling hints. Use `inspect` plus `transition` for lifecycle changes and the
|
|
88
|
+
claim commands below for coordination.
|
|
89
|
+
|
|
58
90
|
## Writing
|
|
59
91
|
|
|
60
92
|
Every write is an explicit, reviewable Git change. Show the user the command
|
|
@@ -26,6 +26,18 @@ const STANDARD_LIMITS = Object.freeze({
|
|
|
26
26
|
});
|
|
27
27
|
const PROBE_LIVE_CORE = Symbol('probe-live-core');
|
|
28
28
|
|
|
29
|
+
function invalidDescribeResponse(details) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
bootstrap_wire_version: 1,
|
|
33
|
+
error: {
|
|
34
|
+
code: 'invalid-describe-request',
|
|
35
|
+
message: 'The adapter describe request is invalid.',
|
|
36
|
+
details,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
export function standardDynamicResult(manifest, coreProbe) {
|
|
30
42
|
return {
|
|
31
43
|
ok: true,
|
|
@@ -296,17 +308,19 @@ export async function runAdapterEntrypoint({
|
|
|
296
308
|
details: incoming.detail,
|
|
297
309
|
},
|
|
298
310
|
}
|
|
299
|
-
:
|
|
311
|
+
: invalidDescribeResponse(incoming.detail);
|
|
300
312
|
await writeBootstrapResponse(process.stdout, response);
|
|
301
313
|
return;
|
|
302
314
|
}
|
|
303
315
|
|
|
304
316
|
if (operation === 'describe') {
|
|
305
317
|
const described = describeAdapter(incoming.request, manifest, dynamic);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
318
|
+
const response = described.ok
|
|
319
|
+
? described.result
|
|
320
|
+
: described.error_code === 'invalid-describe-request'
|
|
321
|
+
? invalidDescribeResponse({ member: described.detail })
|
|
322
|
+
: { ok: false, error: { code: described.error_code } };
|
|
323
|
+
await writeBootstrapResponse(process.stdout, response);
|
|
310
324
|
return;
|
|
311
325
|
}
|
|
312
326
|
|
package/src/claim-coordinator.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
appendClaimEntry,
|
|
5
|
+
assertClaimJournalCapacity,
|
|
6
|
+
claimJournalPath,
|
|
7
|
+
replayClaimJournal,
|
|
8
|
+
} from './claim-journal.js';
|
|
3
9
|
import { resolveWorkClaimCapability } from './claim-capabilities.js';
|
|
4
10
|
import { readBack } from './claim-operations.js';
|
|
5
11
|
import { reconcileClaimJournal } from './claim-publication.js';
|
|
@@ -7,13 +13,19 @@ import { claimStorePath, resolveVerifiedGitCommonDir, withClaimLock } from './cl
|
|
|
7
13
|
import { readNamespace } from './namespace.js';
|
|
8
14
|
|
|
9
15
|
export async function withLegacyMutationFence(ledgerDirectory, itemId, command, write) {
|
|
10
|
-
|
|
16
|
+
let gitCommonDir;
|
|
17
|
+
try {
|
|
18
|
+
gitCommonDir = await resolveVerifiedGitCommonDir(ledgerDirectory, { failClosed: true });
|
|
19
|
+
} catch {
|
|
20
|
+
return claimStoreUnavailable(command, 'git-verification-failed');
|
|
21
|
+
}
|
|
11
22
|
const namespace = gitCommonDir ? await readNamespace(ledgerDirectory) : null;
|
|
12
23
|
const capability = resolveWorkClaimCapability({ gitCommonDir, namespace });
|
|
13
24
|
if (!capability.claim_protected_publication) return write();
|
|
14
25
|
|
|
15
26
|
const storePath = claimStorePath(gitCommonDir, namespace);
|
|
16
27
|
const journalPath = claimJournalPath(gitCommonDir, namespace);
|
|
28
|
+
let intent = null;
|
|
17
29
|
try {
|
|
18
30
|
return await withClaimLock(storePath, async () => {
|
|
19
31
|
const replayed = await replayClaimJournal(journalPath, namespace);
|
|
@@ -35,13 +47,107 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
|
|
|
35
47
|
const mustRefuse = command === 'create-v1'
|
|
36
48
|
? record.last_epoch !== '0'
|
|
37
49
|
: record.active !== null && observedAt < record.active.expires_at;
|
|
38
|
-
if (
|
|
39
|
-
|
|
50
|
+
if (mustRefuse) return legacyRefusal(command, namespace, itemId, observedAt, record);
|
|
51
|
+
|
|
52
|
+
const authorize = async (expectedRevision, candidateRevision) => {
|
|
53
|
+
const attemptId = randomUUID();
|
|
54
|
+
const intentEntry = {
|
|
55
|
+
type: 'legacy-mutation-intent',
|
|
56
|
+
attempt_id: attemptId,
|
|
57
|
+
ledger_namespace: namespace,
|
|
58
|
+
item_id: itemId,
|
|
59
|
+
command,
|
|
60
|
+
expected_revision: expectedRevision,
|
|
61
|
+
candidate_revision: candidateRevision,
|
|
62
|
+
observed_at: observedAt,
|
|
63
|
+
};
|
|
64
|
+
const terminalEntry = {
|
|
65
|
+
type: 'legacy-mutation',
|
|
66
|
+
attempt_id: attemptId,
|
|
67
|
+
ledger_namespace: namespace,
|
|
68
|
+
item_id: itemId,
|
|
69
|
+
command,
|
|
70
|
+
committed_revision: candidateRevision,
|
|
71
|
+
observed_at: observedAt,
|
|
72
|
+
};
|
|
73
|
+
const abortEntry = {
|
|
74
|
+
type: 'legacy-mutation-abort',
|
|
75
|
+
attempt_id: attemptId,
|
|
76
|
+
ledger_namespace: namespace,
|
|
77
|
+
item_id: itemId,
|
|
78
|
+
observed_revision: expectedRevision,
|
|
79
|
+
observed_at: observedAt,
|
|
80
|
+
};
|
|
81
|
+
const resolutionEntry = Buffer.byteLength(JSON.stringify(terminalEntry))
|
|
82
|
+
>= Buffer.byteLength(JSON.stringify(abortEntry))
|
|
83
|
+
? terminalEntry
|
|
84
|
+
: abortEntry;
|
|
85
|
+
await assertClaimJournalCapacity(journalPath, [
|
|
86
|
+
intentEntry,
|
|
87
|
+
{ type: 'clock', now: observedAt, floor: observedAt },
|
|
88
|
+
resolutionEntry,
|
|
89
|
+
]);
|
|
90
|
+
intent = await appendClaimEntry(journalPath, intentEntry);
|
|
91
|
+
};
|
|
92
|
+
let outcome;
|
|
93
|
+
try {
|
|
94
|
+
outcome = await write(authorize);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (intent) {
|
|
97
|
+
return claimStoreUnavailable(command, 'legacy-mutation-outcome-unknown', {
|
|
98
|
+
attempt_id: intent.attempt_id,
|
|
99
|
+
candidate_revision: intent.candidate_revision,
|
|
100
|
+
}, 'unknown');
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (!intent) return outcome;
|
|
105
|
+
if (outcome?.state === 'committed') {
|
|
106
|
+
const committedRevision = outcome.ok === true
|
|
107
|
+
? outcome.item?.revision
|
|
108
|
+
: outcome.error?.details?.revision;
|
|
109
|
+
if (committedRevision !== intent.candidate_revision) {
|
|
110
|
+
return claimStoreUnavailable(command, 'legacy-mutation-outcome-unknown', {
|
|
111
|
+
attempt_id: intent.attempt_id,
|
|
112
|
+
candidate_revision: intent.candidate_revision,
|
|
113
|
+
}, 'unknown');
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await appendClaimEntry(journalPath, {
|
|
117
|
+
type: 'legacy-mutation',
|
|
118
|
+
attempt_id: intent.attempt_id,
|
|
119
|
+
ledger_namespace: namespace,
|
|
120
|
+
item_id: itemId,
|
|
121
|
+
command,
|
|
122
|
+
committed_revision: committedRevision,
|
|
123
|
+
observed_at: observedAt,
|
|
124
|
+
});
|
|
125
|
+
} catch {
|
|
126
|
+
return claimStoreUnavailable(command, 'legacy-mutation-record-failed', {
|
|
127
|
+
attempt_id: intent.attempt_id,
|
|
128
|
+
committed_revision: committedRevision,
|
|
129
|
+
}, 'unknown');
|
|
130
|
+
}
|
|
131
|
+
} else if (outcome?.state === 'unchanged') {
|
|
132
|
+
try {
|
|
133
|
+
await appendClaimEntry(journalPath, {
|
|
134
|
+
type: 'legacy-mutation-abort',
|
|
135
|
+
attempt_id: intent.attempt_id,
|
|
136
|
+
ledger_namespace: namespace,
|
|
137
|
+
item_id: itemId,
|
|
138
|
+
observed_revision: intent.expected_revision,
|
|
139
|
+
observed_at: observedAt,
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
return claimStoreUnavailable(command, 'legacy-mutation-record-failed');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return outcome;
|
|
40
146
|
});
|
|
41
147
|
} catch (error) {
|
|
42
148
|
return claimStoreUnavailable(command, error?.code === 'CLAIM_LOCK_HELD'
|
|
43
149
|
? 'claim-store-locked'
|
|
44
|
-
: 'claim-store-unreadable');
|
|
150
|
+
: 'claim-store-unreadable', {}, intent ? 'unknown' : 'unchanged');
|
|
45
151
|
}
|
|
46
152
|
}
|
|
47
153
|
|
|
@@ -66,7 +172,7 @@ function legacyRefusal(command, namespace, itemId, observedAt, record) {
|
|
|
66
172
|
};
|
|
67
173
|
}
|
|
68
174
|
|
|
69
|
-
function claimStoreUnavailable(command, reason, details = {}) {
|
|
175
|
+
function claimStoreUnavailable(command, reason, details = {}, state = 'unchanged') {
|
|
70
176
|
return {
|
|
71
177
|
exit: 6,
|
|
72
178
|
stdout: {
|
|
@@ -74,7 +180,7 @@ function claimStoreUnavailable(command, reason, details = {}) {
|
|
|
74
180
|
namespace: 'ledger-mutation',
|
|
75
181
|
command,
|
|
76
182
|
contract_version: 1,
|
|
77
|
-
state
|
|
183
|
+
state,
|
|
78
184
|
error: {
|
|
79
185
|
code: 'claim-store-unavailable',
|
|
80
186
|
message: 'The durable claim store is unavailable.',
|
package/src/claim-journal.js
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdir, open, readFile, stat } from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { validateClaimRequest } from './claim-request.js';
|
|
5
5
|
|
|
6
|
-
import { advanceClockFloor, claimAcquire,
|
|
6
|
+
import { advanceClockFloor, claimAcquire, claimRelease, claimRenew } from './claim-operations.js';
|
|
7
7
|
import { emptyClaimState } from './claim-store.js';
|
|
8
8
|
|
|
9
9
|
const MAX_JOURNAL_ENTRIES = 65536;
|
|
@@ -12,6 +12,9 @@ const MAX_RECONCILE_LOG_BYTES = MAX_JOURNAL_BYTES + 1024;
|
|
|
12
12
|
const JOURNAL_ENTRY_TYPES = new Set([
|
|
13
13
|
'claim',
|
|
14
14
|
'clock',
|
|
15
|
+
'legacy-mutation',
|
|
16
|
+
'legacy-mutation-abort',
|
|
17
|
+
'legacy-mutation-intent',
|
|
15
18
|
'publish-final',
|
|
16
19
|
'publish-finalization',
|
|
17
20
|
'publish-intent',
|
|
@@ -43,10 +46,23 @@ export async function appendClaimEntry(journalPath, entry) {
|
|
|
43
46
|
return persisted;
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
export async function assertClaimJournalCapacity(journalPath, plannedEntries) {
|
|
50
|
+
const entries = await readJournalEntries(journalPath);
|
|
51
|
+
if (entries.length + plannedEntries.length > MAX_JOURNAL_ENTRIES) {
|
|
52
|
+
throw journalCapacityExceeded();
|
|
53
|
+
}
|
|
54
|
+
let plannedBytes = await fileSize(journalPath);
|
|
55
|
+
for (const [index, entry] of plannedEntries.entries()) {
|
|
56
|
+
const persisted = { seq: entries.length + index + 1, ...entry };
|
|
57
|
+
plannedBytes += Buffer.byteLength(`${JSON.stringify(persisted)}\n`);
|
|
58
|
+
}
|
|
59
|
+
if (plannedBytes > MAX_JOURNAL_BYTES) throw journalCapacityExceeded();
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
export async function replayClaimJournal(journalPath, namespace) {
|
|
47
63
|
const entries = await readJournalEntries(journalPath, namespace);
|
|
48
64
|
let state = emptyClaimState(namespace);
|
|
49
|
-
const operations = { acquire: claimAcquire,
|
|
65
|
+
const operations = { acquire: claimAcquire, release: claimRelease, renew: claimRenew };
|
|
50
66
|
for (const entry of entries) {
|
|
51
67
|
if (entry.type === 'clock') {
|
|
52
68
|
advanceClockFloor(state, entry.floor);
|
|
@@ -177,6 +193,33 @@ function validJournalEntry(entry, namespace) {
|
|
|
177
193
|
&& validateClaimRequest(entry.command, entry.request).length === 0
|
|
178
194
|
&& (namespace === null || entry.request.ledger_namespace === namespace);
|
|
179
195
|
}
|
|
196
|
+
if (entry.type === 'legacy-mutation-intent') {
|
|
197
|
+
return typeof entry.attempt_id === 'string'
|
|
198
|
+
&& typeof entry.ledger_namespace === 'string'
|
|
199
|
+
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
200
|
+
&& typeof entry.item_id === 'string'
|
|
201
|
+
&& ['patch-v1', 'transition-v1'].includes(entry.command)
|
|
202
|
+
&& typeof entry.expected_revision === 'string'
|
|
203
|
+
&& typeof entry.candidate_revision === 'string'
|
|
204
|
+
&& typeof entry.observed_at === 'string';
|
|
205
|
+
}
|
|
206
|
+
if (entry.type === 'legacy-mutation') {
|
|
207
|
+
return (!Object.hasOwn(entry, 'attempt_id') || typeof entry.attempt_id === 'string')
|
|
208
|
+
&& typeof entry.ledger_namespace === 'string'
|
|
209
|
+
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
210
|
+
&& typeof entry.item_id === 'string'
|
|
211
|
+
&& ['patch-v1', 'transition-v1'].includes(entry.command)
|
|
212
|
+
&& typeof entry.committed_revision === 'string'
|
|
213
|
+
&& typeof entry.observed_at === 'string';
|
|
214
|
+
}
|
|
215
|
+
if (entry.type === 'legacy-mutation-abort') {
|
|
216
|
+
return typeof entry.attempt_id === 'string'
|
|
217
|
+
&& typeof entry.ledger_namespace === 'string'
|
|
218
|
+
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
219
|
+
&& typeof entry.item_id === 'string'
|
|
220
|
+
&& typeof entry.observed_revision === 'string'
|
|
221
|
+
&& typeof entry.observed_at === 'string';
|
|
222
|
+
}
|
|
180
223
|
if (entry.type === 'publish-intent') {
|
|
181
224
|
return typeof entry.operation_id === 'string'
|
|
182
225
|
&& typeof entry.operation_digest === 'string'
|
package/src/claim-publication.js
CHANGED
|
@@ -24,17 +24,28 @@ const EPOCH = /^(?:[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-
|
|
|
24
24
|
const MAX_CANDIDATE_BYTES = 8388608;
|
|
25
25
|
const MAX_CANDIDATE_BASE64_CHARS = Math.ceil(MAX_CANDIDATE_BYTES / 3) * 4;
|
|
26
26
|
|
|
27
|
+
const PUBLICATION_MEMBERS = [
|
|
28
|
+
'candidate_sha256',
|
|
29
|
+
'candidate_source_base64',
|
|
30
|
+
'claim_fence',
|
|
31
|
+
'expected_revision',
|
|
32
|
+
'item_id',
|
|
33
|
+
'ledger_namespace',
|
|
34
|
+
'operation_id',
|
|
35
|
+
];
|
|
36
|
+
|
|
27
37
|
export function validatePublicationRequest(request) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
if (isExactObject(request, ['operation_id'])
|
|
39
|
+
&& OPERATION_ID.test(request.operation_id)) {
|
|
40
|
+
return publicationError(
|
|
41
|
+
request,
|
|
42
|
+
'invalid-request',
|
|
43
|
+
'The publish-claimed retry must include its complete request.',
|
|
44
|
+
{},
|
|
45
|
+
2,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (!isExactObject(request, PUBLICATION_MEMBERS)
|
|
38
49
|
|| !OPERATION_ID.test(request.operation_id)
|
|
39
50
|
|| !NAMESPACE_ID.test(request.ledger_namespace)
|
|
40
51
|
|| !ITEM_ID.test(request.item_id)
|
|
@@ -89,43 +100,40 @@ export function validatePublicationReadRequest(request) {
|
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
export async function readPublicationOutcome({ gitCommonDir, namespace, request }) {
|
|
92
|
-
const storePath = claimStorePath(gitCommonDir, namespace);
|
|
93
103
|
const journalPath = claimJournalPath(gitCommonDir, namespace);
|
|
94
104
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
entry.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
'The publication operation outcome was not found.', {
|
|
105
|
-
operation_id: request.operation_id,
|
|
106
|
-
ledger_namespace: request.ledger_namespace,
|
|
107
|
-
item_id: request.item_id,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
return {
|
|
111
|
-
exit: 0,
|
|
112
|
-
stdout: {
|
|
113
|
-
ok: true,
|
|
114
|
-
namespace: 'ledger-publication',
|
|
115
|
-
command: 'read',
|
|
116
|
-
contract_version: 1,
|
|
117
|
-
state: 'committed',
|
|
105
|
+
const replayed = await replayClaimJournal(journalPath, namespace);
|
|
106
|
+
const outcome = replayed.entries.find((entry) => (
|
|
107
|
+
entry.type === 'publish-final'
|
|
108
|
+
&& entry.operation_id === request.operation_id
|
|
109
|
+
&& entry.item_id === request.item_id
|
|
110
|
+
));
|
|
111
|
+
if (!outcome) {
|
|
112
|
+
return publicationReadError(request, 'operation-not-found',
|
|
113
|
+
'The publication operation outcome was not found.', {
|
|
118
114
|
operation_id: request.operation_id,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
115
|
+
ledger_namespace: request.ledger_namespace,
|
|
116
|
+
item_id: request.item_id,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
exit: 0,
|
|
121
|
+
stdout: {
|
|
122
|
+
ok: true,
|
|
123
|
+
namespace: 'ledger-publication',
|
|
124
|
+
command: 'read',
|
|
125
|
+
contract_version: 1,
|
|
126
|
+
state: 'committed',
|
|
127
|
+
operation_id: request.operation_id,
|
|
128
|
+
result: {
|
|
129
|
+
operation_id: request.operation_id,
|
|
130
|
+
ledger_namespace: request.ledger_namespace,
|
|
131
|
+
item_id: request.item_id,
|
|
132
|
+
operation_digest: outcome.operation_digest,
|
|
133
|
+
outcome: structuredClone(outcome.outcome),
|
|
126
134
|
},
|
|
127
|
-
}
|
|
128
|
-
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
129
137
|
} catch (error) {
|
|
130
138
|
return publicationReadError(request, 'claim-store-unavailable',
|
|
131
139
|
'The durable claim store is unavailable.', {
|
|
@@ -282,6 +290,49 @@ export async function reconcileClaimJournal({
|
|
|
282
290
|
error.code = 'CLOCK_FLOOR_PERSISTENCE_FAILED';
|
|
283
291
|
throw error;
|
|
284
292
|
}
|
|
293
|
+
const legacyMutationTerminals = new Set(entries
|
|
294
|
+
.filter((entry) => (
|
|
295
|
+
['legacy-mutation', 'legacy-mutation-abort'].includes(entry.type)
|
|
296
|
+
&& typeof entry.attempt_id === 'string'
|
|
297
|
+
))
|
|
298
|
+
.map((entry) => entry.attempt_id));
|
|
299
|
+
const pendingLegacyMutations = entries.filter((entry) => (
|
|
300
|
+
entry.type === 'legacy-mutation-intent'
|
|
301
|
+
&& !legacyMutationTerminals.has(entry.attempt_id)
|
|
302
|
+
));
|
|
303
|
+
for (const intent of pendingLegacyMutations) {
|
|
304
|
+
const item = items.get(intent.item_id);
|
|
305
|
+
const actualRevision = item ? revisionFor(item.bytes) : null;
|
|
306
|
+
if (actualRevision === intent.candidate_revision) {
|
|
307
|
+
entries.push(await appendClaimEntry(journalPath, {
|
|
308
|
+
type: 'legacy-mutation',
|
|
309
|
+
attempt_id: intent.attempt_id,
|
|
310
|
+
ledger_namespace: namespace,
|
|
311
|
+
item_id: intent.item_id,
|
|
312
|
+
command: intent.command,
|
|
313
|
+
committed_revision: actualRevision,
|
|
314
|
+
observed_at: observedAt,
|
|
315
|
+
}));
|
|
316
|
+
} else if (actualRevision === intent.expected_revision) {
|
|
317
|
+
entries.push(await appendClaimEntry(journalPath, {
|
|
318
|
+
type: 'legacy-mutation-abort',
|
|
319
|
+
attempt_id: intent.attempt_id,
|
|
320
|
+
ledger_namespace: namespace,
|
|
321
|
+
item_id: intent.item_id,
|
|
322
|
+
observed_revision: actualRevision,
|
|
323
|
+
observed_at: observedAt,
|
|
324
|
+
}));
|
|
325
|
+
} else {
|
|
326
|
+
findings.push({
|
|
327
|
+
code: 'legacy-mutation-outcome-unknown',
|
|
328
|
+
item_id: intent.item_id,
|
|
329
|
+
attempt_id: intent.attempt_id,
|
|
330
|
+
actual_revision: actualRevision,
|
|
331
|
+
expected_revision: intent.expected_revision,
|
|
332
|
+
candidate_revision: intent.candidate_revision,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
285
336
|
|
|
286
337
|
for (const intent of pending) {
|
|
287
338
|
const item = items.get(intent.item_id);
|
|
@@ -394,29 +445,40 @@ export async function reconcileClaimJournal({
|
|
|
394
445
|
}
|
|
395
446
|
}
|
|
396
447
|
|
|
397
|
-
const
|
|
398
|
-
.filter((entry) =>
|
|
448
|
+
const coordinatedItems = new Set(entries
|
|
449
|
+
.filter((entry) => (
|
|
450
|
+
entry.type === 'publish-finalization'
|
|
451
|
+
|| entry.type === 'legacy-mutation'
|
|
452
|
+
))
|
|
399
453
|
.map((entry) => entry.item_id));
|
|
400
|
-
for (const itemId of
|
|
401
|
-
const
|
|
402
|
-
entry.type === 'publish-final'
|
|
454
|
+
for (const itemId of coordinatedItems) {
|
|
455
|
+
const authorized = entries.filter((entry) => (
|
|
456
|
+
(entry.type === 'publish-final'
|
|
403
457
|
&& entry.item_id === itemId
|
|
404
|
-
&& entry.outcome?.stdout?.state === 'committed'
|
|
458
|
+
&& entry.outcome?.stdout?.state === 'committed')
|
|
459
|
+
|| (entry.type === 'legacy-mutation' && entry.item_id === itemId)
|
|
405
460
|
));
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
|
|
461
|
+
const latestAuthorized = authorized.at(-1);
|
|
462
|
+
const expectedRevision = latestAuthorized?.type === 'legacy-mutation'
|
|
463
|
+
? latestAuthorized.committed_revision
|
|
464
|
+
: latestAuthorized?.outcome.stdout.result.committed_revision;
|
|
465
|
+
const authorizedRevisions = new Set(authorized.map((entry) => (
|
|
466
|
+
entry.type === 'legacy-mutation'
|
|
467
|
+
? entry.committed_revision
|
|
468
|
+
: entry.outcome.stdout.result.committed_revision
|
|
469
|
+
)));
|
|
470
|
+
for (const intent of entries) {
|
|
471
|
+
if (intent.type === 'legacy-mutation-intent' && intent.item_id === itemId) {
|
|
472
|
+
authorizedRevisions.add(intent.expected_revision);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (!expectedRevision) continue;
|
|
412
476
|
const item = items.get(itemId);
|
|
413
477
|
const actualRevision = item ? revisionFor(item.bytes) : null;
|
|
414
478
|
const headItem = headItems.get(itemId);
|
|
415
479
|
const headRevision = headItem ? revisionFor(headItem.bytes) : null;
|
|
416
480
|
const workingTreeChanged = actualRevision !== expectedRevision;
|
|
417
|
-
const gitHeadChanged = gitHead !== null
|
|
418
|
-
&& headRevision !== expectedRevision
|
|
419
|
-
&& headRevision !== finalizedRevision;
|
|
481
|
+
const gitHeadChanged = gitHead !== null && !authorizedRevisions.has(headRevision);
|
|
420
482
|
if (!workingTreeChanged && !gitHeadChanged) continue;
|
|
421
483
|
const record = replayed.state.claims.find((entry) => entry.item_id === itemId);
|
|
422
484
|
findings.push({
|
|
@@ -438,7 +500,7 @@ export async function reconcileClaimJournal({
|
|
|
438
500
|
|
|
439
501
|
|
|
440
502
|
for (const record of replayed.state.claims) {
|
|
441
|
-
if (
|
|
503
|
+
if (coordinatedItems.has(record.item_id)) continue;
|
|
442
504
|
if (record.active === null || observedAt >= record.active.expires_at) continue;
|
|
443
505
|
const committed = entries.filter((entry) => (
|
|
444
506
|
entry.type === 'publish-final'
|