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,136 @@
|
|
|
1
|
+
---
|
|
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.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Wowbagger
|
|
7
|
+
|
|
8
|
+
A work ledger that is plain Markdown in Git. Every item is a file; every change
|
|
9
|
+
is a reviewable diff. The core is read-only unless you explicitly ask it to
|
|
10
|
+
publish something.
|
|
11
|
+
|
|
12
|
+
## Before anything else: check the core
|
|
13
|
+
|
|
14
|
+
This skill does **not** bundle the wowbagger core. It drives an installed one,
|
|
15
|
+
so a version mismatch is detectable rather than silent.
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
wowbagger capabilities --json
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Read `contract_version` from the result. **This skill requires
|
|
22
|
+
`contract_version: 2`.**
|
|
23
|
+
|
|
24
|
+
- Command not found → the core is not installed. Tell the user, point them at
|
|
25
|
+
<https://github.com/lstutzman/wowbagger>, and stop. Do not fall back to
|
|
26
|
+
editing ledger files by hand — hand-edits bypass validation and atomic
|
|
27
|
+
publication, which is the whole point of the tool.
|
|
28
|
+
- `contract_version` is anything other than `2` → stop and say so plainly. A
|
|
29
|
+
core reporting `1` predates schema version 2, where `depends_on` records
|
|
30
|
+
declared prerequisites rather than only live blockers; an older or newer core
|
|
31
|
+
may have changed the request or response shape. Do not guess.
|
|
32
|
+
|
|
33
|
+
Run this once per session before the first ledger command, not before every
|
|
34
|
+
command.
|
|
35
|
+
|
|
36
|
+
## Reading
|
|
37
|
+
|
|
38
|
+
These never modify anything. Prefer them.
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
wowbagger validate --ledger <dir> --json
|
|
42
|
+
wowbagger ready --ledger <dir> --as-of <YYYY-MM-DD> --json
|
|
43
|
+
wowbagger inspect --ledger <dir> --id wb_... --json
|
|
44
|
+
wowbagger capabilities --json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `ready` is the source of truth for what is workable. Never infer the queue by
|
|
48
|
+
reading files yourself — dependency and parent rules decide it, and a
|
|
49
|
+
directory listing does not.
|
|
50
|
+
- `--as-of` is a real calendar date. Use today's date; do not invent one to make
|
|
51
|
+
an item appear.
|
|
52
|
+
- `validate` returns `{"valid":true,"errors":[]}` on a clean ledger and exits
|
|
53
|
+
nonzero on a broken one. Run it after any change you make.
|
|
54
|
+
- `inspect` returns a lossless byte snapshot plus a SHA-256 revision. That
|
|
55
|
+
revision is what `transition` compares against, so inspect immediately before
|
|
56
|
+
transitioning.
|
|
57
|
+
|
|
58
|
+
## Writing
|
|
59
|
+
|
|
60
|
+
Every write is an explicit, reviewable Git change. Show the user the command
|
|
61
|
+
before running it.
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
wowbagger create --ledger <dir> --input request.json --json
|
|
65
|
+
wowbagger transition --ledger <dir> --input request.json --json
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- `create` publishes only a caller-supplied canonical ID, atomically and
|
|
69
|
+
no-clobber. It will not invent an ID for you.
|
|
70
|
+
- `transition` changes **one** item. If the change would require touching a
|
|
71
|
+
dependent or a child, it refuses. That refusal is correct — make it a
|
|
72
|
+
reviewable multi-file Git change instead of forcing it.
|
|
73
|
+
- See `docs/mutation-contract.md` in the wowbagger repository for the request
|
|
74
|
+
and response shapes.
|
|
75
|
+
- After any write, run `validate` and show the user the resulting diff.
|
|
76
|
+
|
|
77
|
+
## Work claims are merge-coordinated
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
wowbagger provision --ledger <dir> --json
|
|
81
|
+
wowbagger claim capabilities --ledger <dir> --json
|
|
82
|
+
wowbagger claim read|acquire|renew|release --ledger <dir> --input request.json --json
|
|
83
|
+
wowbagger publish-claimed --ledger <dir> --input request.json --json
|
|
84
|
+
wowbagger claim-verify --ledger <dir> --json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The Git-backed capability reports `mode: "merge-coordinated"` and
|
|
88
|
+
`safe_exclusive_dispatch: false`. Say both facts plainly. An acquire uses
|
|
89
|
+
observed-state compare-and-swap, and a claimed publication checks the active
|
|
90
|
+
owner generation and expected item revision. This protects cooperating
|
|
91
|
+
worktrees that use the protocol.
|
|
92
|
+
|
|
93
|
+
It is not exclusive coordination. Direct filesystem writes, hostile processes,
|
|
94
|
+
other clones, and alternate tools can bypass the protocol. Never present a
|
|
95
|
+
claim as a lock or build a dispatch loop that requires exclusive ownership.
|
|
96
|
+
|
|
97
|
+
Use the claimed write path as one complete loop:
|
|
98
|
+
|
|
99
|
+
1. Run `provision` once for the ledger. Keep its `ledger_namespace`.
|
|
100
|
+
2. Run `claim capabilities --ledger <dir> --json`. Stop if the namespace is
|
|
101
|
+
absent or the mode is not `merge-coordinated`.
|
|
102
|
+
3. Read the current claim record. Acquire with its observed state in
|
|
103
|
+
`expected`; keep the returned `owner_id`, `epoch`, and expiry as the fence.
|
|
104
|
+
4. Renew before the lease expires if the work continues.
|
|
105
|
+
5. Inspect the item again. Build the complete desired item bytes.
|
|
106
|
+
6. Call `publish-claimed` with a unique operation ID, the exact inspected
|
|
107
|
+
revision, the candidate bytes and digest, and the active claim fence. Never
|
|
108
|
+
retry with only the operation ID; retry the complete request.
|
|
109
|
+
7. Commit the item change, or merge the worker commit into the coordinating
|
|
110
|
+
branch.
|
|
111
|
+
8. Run `claim-verify` after the commit or merge. It finalizes the Git outcome,
|
|
112
|
+
repairs response-loss cases, and reports later revision drift. Run it again
|
|
113
|
+
before the next fenced operation if the prior outcome was not final.
|
|
114
|
+
9. Release the claim with its current observed state.
|
|
115
|
+
10. Run `validate` and show the resulting diff.
|
|
116
|
+
|
|
117
|
+
Legacy `create` refuses an ID with claim history. Legacy `transition` refuses an
|
|
118
|
+
item with an active claim. Do not bypass those refusals. Read
|
|
119
|
+
`docs/work-claim-contract.md` in the wowbagger repository for exact request and
|
|
120
|
+
response envelopes, refusal precedence, and recovery rules.
|
|
121
|
+
|
|
122
|
+
## Working the unclaimed loop
|
|
123
|
+
|
|
124
|
+
1. `validate` the ledger.
|
|
125
|
+
2. `ready --as-of <today>` to see what is actually workable.
|
|
126
|
+
3. `inspect` the item you intend to take, and read its body — the acceptance
|
|
127
|
+
criteria live there, not in the metadata.
|
|
128
|
+
4. Do the work.
|
|
129
|
+
5. `inspect` again for the current revision, then `transition` to close it.
|
|
130
|
+
6. `validate`, then show the diff.
|
|
131
|
+
|
|
132
|
+
## Friction is a finding
|
|
133
|
+
|
|
134
|
+
If the tool makes something harder than doing it by hand, that is a defect worth
|
|
135
|
+
recording. File it as a ledger item rather than leaving it in a transcript —
|
|
136
|
+
that is what the ledger is for, and it is how this tool is meant to improve.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { hasExactMembers } from './schema-helpers.js';
|
|
4
|
+
|
|
5
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
6
|
+
const NONCE = /^[A-Za-z0-9._-]{16,128}$/;
|
|
7
|
+
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
8
|
+
const SAFE_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
9
|
+
|
|
10
|
+
function refuse(error_code, detail = {}) {
|
|
11
|
+
return { ok: false, error_code, detail };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function canonicalJson(value) {
|
|
15
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
16
|
+
if (value !== null && typeof value === 'object') {
|
|
17
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
18
|
+
}
|
|
19
|
+
return JSON.stringify(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function decodeCanonicalBase64(value) {
|
|
23
|
+
if (typeof value !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const bytes = Buffer.from(value, 'base64');
|
|
27
|
+
return bytes.toString('base64') === value ? bytes : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validCanonicalTime(value) {
|
|
31
|
+
const parsed = Date.parse(value);
|
|
32
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString().replace('.000Z', 'Z') === value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validInvocationBinding(value) {
|
|
36
|
+
if (!hasExactMembers(value, [
|
|
37
|
+
'request_id', 'adapter', 'core', 'workspace', 'limits',
|
|
38
|
+
'instruction_set_digest', 'handoff_digest',
|
|
39
|
+
]) || !SAFE_ID.test(value.request_id)) return false;
|
|
40
|
+
if (!hasExactMembers(value.adapter, ['id', 'version', 'contract_version'])
|
|
41
|
+
|| typeof value.adapter.id !== 'string'
|
|
42
|
+
|| typeof value.adapter.version !== 'string'
|
|
43
|
+
|| !Number.isSafeInteger(value.adapter.contract_version)) return false;
|
|
44
|
+
if (!hasExactMembers(value.core, [
|
|
45
|
+
'executable_identity', 'contract_version', 'argv', 'input_base64',
|
|
46
|
+
])
|
|
47
|
+
|| !DIGEST.test(value.core.executable_identity)
|
|
48
|
+
|| !Number.isSafeInteger(value.core.contract_version)
|
|
49
|
+
|| !Array.isArray(value.core.argv)
|
|
50
|
+
|| !value.core.argv.every((argument) => typeof argument === 'string')
|
|
51
|
+
|| decodeCanonicalBase64(value.core.input_base64) === null) return false;
|
|
52
|
+
if (!hasExactMembers(value.workspace, ['id', 'root', 'cwd', 'ledger'])
|
|
53
|
+
|| !Object.values(value.workspace).every((member) => typeof member === 'string')) return false;
|
|
54
|
+
if (!hasExactMembers(value.limits, [
|
|
55
|
+
'context_bytes', 'stdout_bytes', 'stderr_bytes', 'timeout_ms',
|
|
56
|
+
])
|
|
57
|
+
|| !Object.values(value.limits).every((member) => Number.isSafeInteger(member) && member >= 0)
|
|
58
|
+
|| value.limits.timeout_ms < 1) return false;
|
|
59
|
+
return DIGEST.test(value.instruction_set_digest)
|
|
60
|
+
&& (value.handoff_digest === null || DIGEST.test(value.handoff_digest));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function approvalSchemaError(value) {
|
|
64
|
+
if (!hasExactMembers(value, [
|
|
65
|
+
'approval_version', 'source', 'nonce', 'issued_at', 'expires_at', 'invocation_digest',
|
|
66
|
+
])) return true;
|
|
67
|
+
return value.approval_version !== 1
|
|
68
|
+
|| typeof value.source !== 'string'
|
|
69
|
+
|| typeof value.nonce !== 'string'
|
|
70
|
+
|| !NONCE.test(value.nonce)
|
|
71
|
+
|| !DIGEST.test(value.invocation_digest)
|
|
72
|
+
|| !RFC3339.test(value.issued_at)
|
|
73
|
+
|| !validCanonicalTime(value.issued_at)
|
|
74
|
+
|| !RFC3339.test(value.expires_at)
|
|
75
|
+
|| !validCanonicalTime(value.expires_at);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function canonicalInvocationDigest(binding) {
|
|
79
|
+
if (!validInvocationBinding(binding)) throw new TypeError('invalid invocation binding');
|
|
80
|
+
const canonical = canonicalJson(binding);
|
|
81
|
+
return {
|
|
82
|
+
canonical,
|
|
83
|
+
digest: `sha256:${createHash('sha256').update(Buffer.from(canonical)).digest('hex')}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function verifyTrustedApproval({
|
|
88
|
+
approval,
|
|
89
|
+
binding,
|
|
90
|
+
now,
|
|
91
|
+
redeemedNonces,
|
|
92
|
+
trustedSources = new Set(['consumer']),
|
|
93
|
+
}) {
|
|
94
|
+
if (approvalSchemaError(approval)) return refuse('invalid-approval');
|
|
95
|
+
if (!(trustedSources instanceof Set)
|
|
96
|
+
|| trustedSources.size !== 1
|
|
97
|
+
|| !trustedSources.has('consumer')
|
|
98
|
+
|| approval.source !== 'consumer') {
|
|
99
|
+
return refuse('approval-source-untrusted', { source: approval.source });
|
|
100
|
+
}
|
|
101
|
+
if (!RFC3339.test(now) || !validCanonicalTime(now)) {
|
|
102
|
+
return refuse('invalid-approval-time', { member: 'now' });
|
|
103
|
+
}
|
|
104
|
+
const issued = Date.parse(approval.issued_at);
|
|
105
|
+
const expires = Date.parse(approval.expires_at);
|
|
106
|
+
const current = Date.parse(now);
|
|
107
|
+
if (issued >= expires) return refuse('invalid-approval-time-order');
|
|
108
|
+
if (current < issued) return refuse('approval-not-yet-valid', { issued_at: approval.issued_at });
|
|
109
|
+
if (current >= expires) return refuse('approval-expired', { expires_at: approval.expires_at });
|
|
110
|
+
if (!(redeemedNonces instanceof Set)) return refuse('invalid-approval');
|
|
111
|
+
if (redeemedNonces.has(approval.nonce)) {
|
|
112
|
+
return refuse('approval-replayed', { nonce: approval.nonce });
|
|
113
|
+
}
|
|
114
|
+
let invocationDigest;
|
|
115
|
+
try {
|
|
116
|
+
invocationDigest = canonicalInvocationDigest(binding).digest;
|
|
117
|
+
} catch {
|
|
118
|
+
return refuse('invalid-approval-binding');
|
|
119
|
+
}
|
|
120
|
+
if (invocationDigest !== approval.invocation_digest) return refuse('approval-binding-mismatch');
|
|
121
|
+
redeemedNonces.add(approval.nonce);
|
|
122
|
+
return { ok: true, nonce: approval.nonce };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function verifyMutationAuthority({ command, approval, approvalOptions }) {
|
|
126
|
+
if (command !== 'create' && command !== 'transition' && command !== 'patch') {
|
|
127
|
+
return { ok: true, authority: [] };
|
|
128
|
+
}
|
|
129
|
+
if (approval === null || approval === undefined) {
|
|
130
|
+
return refuse('consumer-approval-required', { command });
|
|
131
|
+
}
|
|
132
|
+
const verified = verifyTrustedApproval({ approval, ...approvalOptions });
|
|
133
|
+
if (!verified.ok) return verified;
|
|
134
|
+
return { ok: true, authority: [`core:${command}`], nonce: verified.nonce };
|
|
135
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { normalizeJsonValue, parseJsonRequest } from '../request.js';
|
|
2
|
+
|
|
3
|
+
// The bootstrap wire (contract section 3.3): exactly one strict UTF-8 JSON
|
|
4
|
+
// object in on stdin, then stdin closes; exactly one strict JSON object plus
|
|
5
|
+
// one LF out on stdout. `readBootstrapRequest` collects bounded stream
|
|
6
|
+
// bytes before parsing — `parseJsonRequest` decodes UTF-8 fatally and reports
|
|
7
|
+
// duplicate members and trailing bytes through `issues` rather than
|
|
8
|
+
// throwing, so a non-empty `issues` array is the only acceptable-parse gate.
|
|
9
|
+
export async function readBootstrapRequest(stream, { maxBytes, errorCode = 'invalid-describe-request' } = {}) {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
let byteLength = 0;
|
|
12
|
+
// A stream error must surface as a refusal, never as an uncaught rejection: the
|
|
13
|
+
// entrypoint has to emit exactly one JSON object plus one LF and exit zero on
|
|
14
|
+
// every path (§3.3), including a stdin read failure.
|
|
15
|
+
try {
|
|
16
|
+
for await (const chunk of stream) {
|
|
17
|
+
byteLength += chunk.length;
|
|
18
|
+
if (maxBytes !== undefined && byteLength > maxBytes) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error_code: errorCode,
|
|
22
|
+
detail: { member: 'request', reason: 'byte-limit-exceeded', limit_bytes: maxBytes },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
chunks.push(chunk);
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
return { ok: false, error_code: errorCode, detail: { member: 'request', reason: 'stream-error' } };
|
|
29
|
+
}
|
|
30
|
+
const bytes = Buffer.concat(chunks);
|
|
31
|
+
const parsed = parseJsonRequest(bytes);
|
|
32
|
+
if (parsed.issues.length > 0) {
|
|
33
|
+
return { ok: false, error_code: errorCode, detail: { member: 'request_json' } };
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, request: normalizeJsonValue(parsed.value), bytes };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Writes exactly one JSON object plus one trailing LF, and nothing else.
|
|
39
|
+
export function writeBootstrapResponse(stream, response) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
stream.write(`${JSON.stringify(response)}\n`, (error) => (error ? reject(error) : resolve()));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { validateHandoffCarrier } from './handoff.js';
|
|
2
|
+
import { validateInstructionInput } from './instructions.js';
|
|
3
|
+
|
|
4
|
+
function refuse(error_code, detail = {}) {
|
|
5
|
+
return { ok: false, error_code, detail };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function validateInvokeContext({
|
|
9
|
+
instruction_input: instructionInput,
|
|
10
|
+
handoff_carrier: handoffCarrier,
|
|
11
|
+
context_bytes: contextBytes,
|
|
12
|
+
instruction_limits: instructionLimits,
|
|
13
|
+
handoff_options: handoffOptions,
|
|
14
|
+
}) {
|
|
15
|
+
const instructions = validateInstructionInput(instructionInput, instructionLimits);
|
|
16
|
+
if (!instructions.ok) return instructions;
|
|
17
|
+
const handoff = handoffCarrier === null
|
|
18
|
+
? { ok: true, byte_length: 0 }
|
|
19
|
+
: validateHandoffCarrier(handoffCarrier, handoffOptions);
|
|
20
|
+
if (!handoff.ok) return handoff;
|
|
21
|
+
if (handoffCarrier !== null
|
|
22
|
+
&& handoffCarrier.resume_request.instruction_set_digest !== instructions.instruction_set_digest) {
|
|
23
|
+
return refuse('handoff-instruction-set-mismatch');
|
|
24
|
+
}
|
|
25
|
+
const totalBytes = instructions.total_bytes + handoff.byte_length;
|
|
26
|
+
if (totalBytes > contextBytes) {
|
|
27
|
+
return refuse('context-limit-exceeded', {
|
|
28
|
+
instruction_bytes: instructions.total_bytes,
|
|
29
|
+
handoff_bytes: handoff.byte_length,
|
|
30
|
+
context_bytes: contextBytes,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return { ok: true, total_bytes: totalBytes, instructions, handoff };
|
|
34
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { hasExactMembers } from './schema-helpers.js';
|
|
2
|
+
|
|
3
|
+
// The version 2 core command list, in the fixed advertising order (contract
|
|
4
|
+
// section 3). `describe.js` also needs this order to validate the
|
|
5
|
+
// `core.commands` subset it accepts, so it is exported from here.
|
|
6
|
+
export const CORE_COMMAND_ORDER = Object.freeze([
|
|
7
|
+
'capabilities', 'create', 'inspect', 'patch', 'ready', 'transition', 'validate',
|
|
8
|
+
]);
|
|
9
|
+
export const CORE_CONTRACT_VERSION = 2;
|
|
10
|
+
|
|
11
|
+
function refuse(error_code, detail) {
|
|
12
|
+
return { ok: false, error_code, detail };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function backendIssue(backend) {
|
|
16
|
+
if (!hasExactMembers(backend, ['name', 'coordination_scope'])
|
|
17
|
+
|| backend.name !== 'local-filesystem'
|
|
18
|
+
|| backend.coordination_scope !== 'same-working-copy-cooperative-writers') {
|
|
19
|
+
return 'result.backend';
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function inspectOperationIssue(inspect) {
|
|
25
|
+
if (!hasExactMembers(inspect, ['supported', 'write_scope', 'cas_scope'])
|
|
26
|
+
|| inspect.supported !== true
|
|
27
|
+
|| inspect.write_scope !== 'none'
|
|
28
|
+
|| inspect.cas_scope !== 'none') {
|
|
29
|
+
return 'result.operations.inspect';
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createOperationIssue(create) {
|
|
35
|
+
if (!hasExactMembers(create, [
|
|
36
|
+
'supported', 'write_scope', 'cas_scope', 'publication_visibility', 'publication_probe',
|
|
37
|
+
])
|
|
38
|
+
|| create.supported !== true
|
|
39
|
+
|| create.write_scope !== 'single-item'
|
|
40
|
+
|| create.cas_scope !== 'requested-id-lock'
|
|
41
|
+
|| create.publication_visibility !== 'atomic-no-clobber-or-fail'
|
|
42
|
+
|| create.publication_probe !== 'per-ledger-operation') {
|
|
43
|
+
return 'result.operations.create';
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function transitionOperationIssue(transition) {
|
|
49
|
+
if (!hasExactMembers(transition, ['supported', 'write_scope', 'cas_scope'])
|
|
50
|
+
|| transition.supported !== true
|
|
51
|
+
|| transition.write_scope !== 'single-item'
|
|
52
|
+
|| transition.cas_scope !== 'exact-byte-sha256') {
|
|
53
|
+
return 'result.operations.transition';
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function patchOperationIssue(patch) {
|
|
59
|
+
if (!hasExactMembers(patch, ['supported', 'write_scope', 'cas_scope'])
|
|
60
|
+
|| patch.supported !== true
|
|
61
|
+
|| patch.write_scope !== 'single-item'
|
|
62
|
+
|| patch.cas_scope !== 'exact-byte-sha256') {
|
|
63
|
+
return 'result.operations.patch';
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Every member except `supported` is a permanent advisory-claims invariant:
|
|
69
|
+
// claims never protect publication, never fence writers, and must never
|
|
70
|
+
// advertise safe exclusive dispatch.
|
|
71
|
+
function workClaimOperationIssue(workClaim) {
|
|
72
|
+
if (!hasExactMembers(workClaim, [
|
|
73
|
+
'supported', 'api_version', 'mode', 'claim_protected_publication',
|
|
74
|
+
'fencing_enforced_at', 'safe_exclusive_dispatch',
|
|
75
|
+
])
|
|
76
|
+
|| typeof workClaim.supported !== 'boolean'
|
|
77
|
+
|| workClaim.api_version !== 1
|
|
78
|
+
|| workClaim.mode !== 'advisory'
|
|
79
|
+
|| workClaim.claim_protected_publication !== false
|
|
80
|
+
|| workClaim.fencing_enforced_at !== 'none'
|
|
81
|
+
|| workClaim.safe_exclusive_dispatch !== false) {
|
|
82
|
+
return 'result.operations.work_claim';
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function operationsIssue(operations) {
|
|
88
|
+
if (!hasExactMembers(operations, ['inspect', 'create', 'transition', 'patch', 'work_claim'])) {
|
|
89
|
+
return 'result.operations';
|
|
90
|
+
}
|
|
91
|
+
return inspectOperationIssue(operations.inspect)
|
|
92
|
+
|| createOperationIssue(operations.create)
|
|
93
|
+
|| transitionOperationIssue(operations.transition)
|
|
94
|
+
|| patchOperationIssue(operations.patch)
|
|
95
|
+
|| workClaimOperationIssue(operations.work_claim);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function durabilityIssue(durability) {
|
|
99
|
+
if (!hasExactMembers(durability, [
|
|
100
|
+
'temporary_file_sync', 'directory_sync', 'post_publication_verification', 'power_loss_guarantee',
|
|
101
|
+
])
|
|
102
|
+
|| durability.temporary_file_sync !== 'required-before-publication'
|
|
103
|
+
|| durability.directory_sync !== 'best-effort-when-supported'
|
|
104
|
+
|| durability.post_publication_verification !== 'exact-bytes-required'
|
|
105
|
+
|| durability.power_loss_guarantee !== 'none') {
|
|
106
|
+
return 'result.durability';
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function limitsIssue(limits) {
|
|
112
|
+
if (!hasExactMembers(limits, [
|
|
113
|
+
'multi_item_atomicity', 'cross_clone_coordination', 'cross_worktree_coordination',
|
|
114
|
+
'cross_machine_coordination', 'noncooperating_writer_protection', 'automatic_stale_lock_breaking',
|
|
115
|
+
])
|
|
116
|
+
|| limits.multi_item_atomicity !== false
|
|
117
|
+
|| limits.cross_clone_coordination !== false
|
|
118
|
+
|| limits.cross_worktree_coordination !== false
|
|
119
|
+
|| limits.cross_machine_coordination !== false
|
|
120
|
+
|| limits.noncooperating_writer_protection !== false
|
|
121
|
+
|| limits.automatic_stale_lock_breaking !== false) {
|
|
122
|
+
return 'result.limits';
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function coreProbeSchemaIssue(probe) {
|
|
128
|
+
if (!hasExactMembers(probe, ['ok', 'command', 'contract_version', 'result'])) {
|
|
129
|
+
return 'members';
|
|
130
|
+
}
|
|
131
|
+
if (probe.ok !== true) {
|
|
132
|
+
return 'ok';
|
|
133
|
+
}
|
|
134
|
+
if (probe.command !== 'capabilities') {
|
|
135
|
+
return 'command';
|
|
136
|
+
}
|
|
137
|
+
if (probe.contract_version !== CORE_CONTRACT_VERSION) {
|
|
138
|
+
return 'contract_version';
|
|
139
|
+
}
|
|
140
|
+
const result = probe.result;
|
|
141
|
+
if (!hasExactMembers(result, ['backend', 'operations', 'durability', 'limits'])) {
|
|
142
|
+
return 'result';
|
|
143
|
+
}
|
|
144
|
+
return backendIssue(result.backend)
|
|
145
|
+
|| operationsIssue(result.operations)
|
|
146
|
+
|| durabilityIssue(result.durability)
|
|
147
|
+
|| limitsIssue(result.limits);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sameCommandOrder(commands) {
|
|
151
|
+
return Array.isArray(commands)
|
|
152
|
+
&& commands.length === CORE_COMMAND_ORDER.length
|
|
153
|
+
&& commands.every((command, index) => command === CORE_COMMAND_ORDER[index]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// verifyCoreProbe(describe, probe) checks the independently-launched core
|
|
157
|
+
// `capabilities --json` probe against an already-validated describe
|
|
158
|
+
// result (contract section 3): the probe must match the exact version 2
|
|
159
|
+
// envelope, its contract version must match the required core contract
|
|
160
|
+
// version, its command list must match the advertised one, and neither
|
|
161
|
+
// optional feature may be elevated beyond what the probe actually supports.
|
|
162
|
+
export function verifyCoreProbe(describe, probe) {
|
|
163
|
+
const schemaIssue = coreProbeSchemaIssue(probe);
|
|
164
|
+
if (schemaIssue) {
|
|
165
|
+
return refuse('core-protocol-error', { member: schemaIssue });
|
|
166
|
+
}
|
|
167
|
+
const required = describe?.core?.required_core_contract_version;
|
|
168
|
+
if (probe.contract_version !== required) {
|
|
169
|
+
return refuse('core-contract-version-mismatch', { required, probed: probe.contract_version });
|
|
170
|
+
}
|
|
171
|
+
if (!sameCommandOrder(describe.core.commands)) {
|
|
172
|
+
return refuse('core-contract-version-mismatch', { member: 'core.commands' });
|
|
173
|
+
}
|
|
174
|
+
if (describe.optional_features?.claims !== probe.result.operations.work_claim.supported) {
|
|
175
|
+
return refuse('core-contract-version-mismatch', { member: 'optional_features.claims' });
|
|
176
|
+
}
|
|
177
|
+
if (describe.optional_features?.policy !== false) {
|
|
178
|
+
return refuse('core-contract-version-mismatch', { member: 'optional_features.policy' });
|
|
179
|
+
}
|
|
180
|
+
return { ok: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The engine's own core capability snapshot: this repository's core is a
|
|
184
|
+
// same-process, same-working-copy `local-filesystem` backend. Advisory
|
|
185
|
+
// work-claim visibility is independent of this mutation-coordination scope
|
|
186
|
+
// (contract section 3: "optional_features.claims is derived only from
|
|
187
|
+
// operations.work_claim.supported").
|
|
188
|
+
export function coreCapabilities() {
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
command: 'capabilities',
|
|
192
|
+
contract_version: CORE_CONTRACT_VERSION,
|
|
193
|
+
result: {
|
|
194
|
+
backend: { name: 'local-filesystem', coordination_scope: 'same-working-copy-cooperative-writers' },
|
|
195
|
+
operations: {
|
|
196
|
+
inspect: { supported: true, write_scope: 'none', cas_scope: 'none' },
|
|
197
|
+
create: {
|
|
198
|
+
supported: true,
|
|
199
|
+
write_scope: 'single-item',
|
|
200
|
+
cas_scope: 'requested-id-lock',
|
|
201
|
+
publication_visibility: 'atomic-no-clobber-or-fail',
|
|
202
|
+
publication_probe: 'per-ledger-operation',
|
|
203
|
+
},
|
|
204
|
+
transition: { supported: true, write_scope: 'single-item', cas_scope: 'exact-byte-sha256' },
|
|
205
|
+
patch: { supported: true, write_scope: 'single-item', cas_scope: 'exact-byte-sha256' },
|
|
206
|
+
work_claim: {
|
|
207
|
+
supported: false,
|
|
208
|
+
api_version: 1,
|
|
209
|
+
mode: 'advisory',
|
|
210
|
+
claim_protected_publication: false,
|
|
211
|
+
fencing_enforced_at: 'none',
|
|
212
|
+
safe_exclusive_dispatch: false,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
durability: {
|
|
216
|
+
temporary_file_sync: 'required-before-publication',
|
|
217
|
+
directory_sync: 'best-effort-when-supported',
|
|
218
|
+
post_publication_verification: 'exact-bytes-required',
|
|
219
|
+
power_loss_guarantee: 'none',
|
|
220
|
+
},
|
|
221
|
+
limits: {
|
|
222
|
+
multi_item_atomicity: false,
|
|
223
|
+
cross_clone_coordination: false,
|
|
224
|
+
cross_worktree_coordination: false,
|
|
225
|
+
cross_machine_coordination: false,
|
|
226
|
+
noncooperating_writer_protection: false,
|
|
227
|
+
automatic_stale_lock_breaking: false,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|