@entrorove/skill 0.1.22 → 0.1.24
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/AGREEMENT.md +6 -6
- package/CHANGELOG.md +10 -0
- package/SKILL.md +12 -4
- package/archguard-runtime-build.mjs +15 -0
- package/blueprint-criterion-runner.mjs +110 -0
- package/blueprint-criterion-wire.mjs +78 -0
- package/calctool-runtime-build.mjs +59 -0
- package/calctool-runtime-entry.mjs +92 -0
- package/cli.mjs +18 -83
- package/local-approved-plan.mjs +74 -15
- package/local-archguard-ast.mjs +371 -0
- package/local-archguard-checkpoint.mjs +123 -0
- package/local-archguard-components.mjs +177 -0
- package/local-archguard-contract.mjs +109 -0
- package/local-archguard-contracts.mjs +42 -0
- package/local-archguard-evaluate.mjs +120 -0
- package/local-archguard-language-ast.mjs +165 -0
- package/local-archguard-mutation-fence.mjs +28 -0
- package/local-archguard-operations.mjs +25 -0
- package/local-archguard-scope.mjs +64 -0
- package/local-archguard-signing.mjs +52 -0
- package/local-archguard-snapshot.mjs +126 -0
- package/local-archguard-state.mjs +200 -0
- package/local-archguard.mjs +74 -0
- package/local-budget-git-attributes.mjs +49 -0
- package/local-budget-git-loader.mjs +61 -0
- package/local-budget-git-native-attributes.mjs +81 -0
- package/local-budget-git-native-darwin.mjs +104 -0
- package/local-budget-git-native-linux.mjs +93 -0
- package/local-budget-git-native-reads.mjs +88 -0
- package/local-budget-git-native-request.mjs +53 -0
- package/local-budget-git-native-runtime.mjs +18 -0
- package/local-budget-git-native.c +313 -0
- package/local-budget-git-native.mjs +109 -0
- package/local-budget-git-outcome.mjs +33 -0
- package/local-budget-git-scratch-loader.mjs +158 -0
- package/local-budget-git-scratch.mjs +112 -0
- package/local-budget-git-system-attributes.mjs +44 -0
- package/local-budget-git.mjs +92 -0
- package/local-budget-grants.mjs +82 -0
- package/local-budget-handoff-completion.mjs +70 -0
- package/local-budget-handoff-contracts.mjs +53 -0
- package/local-budget-handoff.mjs +229 -0
- package/local-budget-lock.mjs +55 -0
- package/local-budget-reads.mjs +135 -0
- package/local-budget-renewal.mjs +86 -34
- package/local-budget-source-bindings.mjs +51 -0
- package/local-budget-store.mjs +131 -0
- package/local-budget-transition.mjs +46 -0
- package/local-budget.mjs +185 -0
- package/local-calctool-cli.mjs +22 -0
- package/local-calctool-contracts.mjs +42 -0
- package/local-calctool-runtime.mjs +10 -0
- package/local-calctool-storage.mjs +101 -0
- package/local-calctool.mjs +103 -0
- package/local-cli.mjs +49 -16
- package/local-contract-types.mjs +46 -0
- package/local-contracts.mjs +124 -0
- package/local-credential.mjs +101 -0
- package/local-decimal.mjs +85 -0
- package/local-evidence-verification.mjs +199 -0
- package/local-execution.mjs +115 -304
- package/local-final-gate16-bundle.mjs +21 -0
- package/local-final-gate16-checks.mjs +88 -0
- package/local-final-gate16-contract.mjs +15 -0
- package/local-final-gate16-plan.mjs +66 -0
- package/local-final-gate16-proof.mjs +75 -0
- package/local-final-gate16-release.mjs +17 -0
- package/local-final-gate16-state.mjs +70 -0
- package/local-final-gate16-worker.mjs +19 -0
- package/local-final-gate16.mjs +137 -0
- package/local-formula-ast.mjs +97 -0
- package/local-formula-engine.mjs +111 -0
- package/local-formula-tests.mjs +61 -0
- package/local-formula.mjs +25 -125
- package/local-integrity.mjs +22 -41
- package/local-invocations.mjs +107 -0
- package/local-ledger.mjs +2 -7
- package/local-merge-archguard-verification.d.mts +44 -0
- package/local-merge-archguard-verification.mjs +240 -0
- package/local-merge-archguard.mjs +138 -0
- package/local-merge-artifacts.mjs +86 -0
- package/local-merge-authority.mjs +89 -0
- package/local-merge-contracts.mjs +56 -0
- package/local-merge-controller.mjs +170 -0
- package/local-merge-head.mjs +56 -0
- package/local-merge-journal.mjs +77 -0
- package/local-merge-mutation.mjs +97 -0
- package/local-merge-rollback.mjs +126 -0
- package/local-merge-snapshot-rollback.mjs +26 -0
- package/local-merge-storage.mjs +103 -0
- package/local-merge-targets.mjs +47 -0
- package/local-merge-transaction.mjs +133 -0
- package/local-mutation-audit.mjs +34 -0
- package/local-mutation-authority.mjs +96 -0
- package/local-mutation-batch-receipt.mjs +84 -0
- package/local-mutation-batch.mjs +143 -0
- package/local-mutation-binding.mjs +86 -0
- package/local-mutation-files.mjs +245 -0
- package/local-mutation-pass-store.mjs +63 -0
- package/local-mutation-snapshot.mjs +200 -0
- package/local-mutation-wire.mjs +89 -0
- package/local-probe.mjs +133 -11
- package/local-scope-candidate.mjs +28 -0
- package/local-scope-context.mjs +28 -0
- package/local-scope-files.mjs +106 -0
- package/local-scope-git-attributes.mjs +200 -0
- package/local-scope-git-comparison.mjs +8 -0
- package/local-scope-git-history.mjs +72 -0
- package/local-scope-git-reads.mjs +66 -0
- package/local-scope-git.mjs +124 -0
- package/local-scope-repository.mjs +249 -0
- package/local-scope-signature.mjs +70 -0
- package/local-scope-snapshot.mjs +39 -0
- package/local-scope-state.mjs +204 -0
- package/local-scope-workspaces.mjs +83 -0
- package/local-swarm-authority.mjs +143 -0
- package/local-swarm-baseline.mjs +32 -0
- package/local-swarm-completion.mjs +102 -0
- package/local-swarm-contracts.mjs +47 -0
- package/local-swarm-mutation.mjs +142 -0
- package/local-swarm-priority.mjs +237 -0
- package/local-swarm-state.mjs +102 -0
- package/local-swarm-storage.mjs +135 -0
- package/local-swarm.mjs +179 -0
- package/local-task-handoff-completion-wire.mjs +71 -0
- package/local-task-handoff-contracts.mjs +66 -0
- package/local-task-handoff-fence.mjs +478 -0
- package/local-task-handoff-observation.mjs +38 -0
- package/local-task-handoff-overlay-wire.mjs +94 -0
- package/local-task-handoff-read-authority.mjs +236 -0
- package/local-task-handoff-storage.mjs +73 -0
- package/local-task-handoff-write-archive.mjs +80 -0
- package/local-task-handoff-write-constraints.mjs +66 -0
- package/local-task-handoff-write-receipts.mjs +99 -0
- package/local-task-handoff-write-wire.mjs +99 -0
- package/local-task-routing.mjs +202 -22
- package/manifest.json +1 -1
- package/package.json +153 -3
- package/release-state.json +153 -17
- package/release.mjs +238 -4
- package/releases.json +12 -0
- package/remote-client.mjs +127 -36
- package/remote-transport.mjs +80 -0
- package/review-client.mjs +38 -0
- package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
- package/runtime/calctool-runtime-v7.0.43.mjs +1887 -0
- package/runtime/git-native-darwin-v1.mjs +5 -0
- package/runtime/git-native-linux-v1.mjs +5 -0
- package/task-message-cli.mjs +47 -0
- package/task-message-entry.mjs +159 -0
- package/workflow-local-client.mjs +35 -0
- package/workflow-local-contracts.mjs +58 -0
- package/workflow-local-request.mjs +53 -0
- package/workflow-local-runner.mjs +350 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { constants, promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { withBudgetLock } from './local-budget-lock.mjs';
|
|
6
|
+
import { BUDGET_SCHEMA, canonical, fail, identifier, sha, verifyBudgetGrant } from './local-budget-grants.mjs';
|
|
7
|
+
|
|
8
|
+
export async function privateDirectory(directory, create = true) {
|
|
9
|
+
const absolute = path.resolve(directory);
|
|
10
|
+
let current = path.parse(absolute).root;
|
|
11
|
+
for (const part of absolute.slice(current.length).split(path.sep).filter(Boolean)) {
|
|
12
|
+
current = path.join(current, part);
|
|
13
|
+
if (create) { try { await fs.mkdir(current, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; } }
|
|
14
|
+
const stat = await fs.lstat(current);
|
|
15
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'budget state directory must be a real directory');
|
|
16
|
+
}
|
|
17
|
+
if (create && process.platform !== 'win32') await fs.chmod(absolute, 0o700);
|
|
18
|
+
}
|
|
19
|
+
export async function budgetLocation(input, createHome = false) {
|
|
20
|
+
if (typeof input.repositoryRoot !== 'string' || !path.isAbsolute(input.repositoryRoot)) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute');
|
|
21
|
+
const stat = await fs.lstat(input.repositoryRoot);
|
|
22
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory');
|
|
23
|
+
const root = await fs.realpath(input.repositoryRoot); const repositoryDigest = sha(root).slice(0, 32);
|
|
24
|
+
const flowId = identifier(input.flowId, 'flowId'); const suppliedHome = input.home ?? os.homedir();
|
|
25
|
+
if (typeof suppliedHome !== 'string' || !path.isAbsolute(suppliedHome)) fail('ENTROROVE_HOME_INVALID', 'home must be absolute');
|
|
26
|
+
if (createHome) await fs.mkdir(suppliedHome, { recursive: true, mode: 0o700 });
|
|
27
|
+
const home = await fs.realpath(suppliedHome);
|
|
28
|
+
const directory = path.join(home, '.entrorove', 'budgets', repositoryDigest, flowId);
|
|
29
|
+
const authorityDirectory = path.join(home, '.entrorove', 'budget-integrity', repositoryDigest, flowId);
|
|
30
|
+
return { root, home, repositoryDigest, flowId, directory, authorityDirectory, authorityFile: path.join(authorityDirectory, 'head.json'),
|
|
31
|
+
file: path.join(directory, 'budget.json'), journal: path.join(directory, 'journal'), keyFile: path.join(authorityDirectory, 'integrity.key') };
|
|
32
|
+
}
|
|
33
|
+
async function regularBytes(file) {
|
|
34
|
+
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
35
|
+
try { if (!(await handle.stat()).isFile()) fail('ENTROROVE_STATE_INVALID', 'budget state must be a regular file'); return await handle.readFile(); } finally { await handle.close(); }
|
|
36
|
+
}
|
|
37
|
+
async function json(file) {
|
|
38
|
+
try {
|
|
39
|
+
const value = JSON.parse((await regularBytes(file)).toString('utf8'));
|
|
40
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('ENTROROVE_BUDGET_CHAIN_INVALID', 'budget controls must contain JSON objects');
|
|
41
|
+
return value;
|
|
42
|
+
} catch (error) { if (error instanceof SyntaxError) fail('ENTROROVE_BUDGET_CHAIN_INVALID', 'budget journal contains invalid JSON'); throw error; }
|
|
43
|
+
}
|
|
44
|
+
async function durableWrite(file, bytes, mode = 0o600) {
|
|
45
|
+
const handle = await fs.open(file, 'wx', mode);
|
|
46
|
+
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
|
|
47
|
+
}
|
|
48
|
+
async function syncDirectory(directory) {
|
|
49
|
+
const handle = await fs.open(directory, 'r');
|
|
50
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
51
|
+
}
|
|
52
|
+
function mac(key, unsigned) { return createHmac('sha256', key).update(canonical(unsigned)).digest('hex'); }
|
|
53
|
+
function verifyRecord(record, key, sequence, previousDigest, location) {
|
|
54
|
+
if (!record.state || typeof record.state !== 'object' || Array.isArray(record.state)) fail('ENTROROVE_BUDGET_CHAIN_INVALID', 'budget journal state must be an object');
|
|
55
|
+
const { digest, signature, ...unsigned } = record;
|
|
56
|
+
const expected = mac(key, unsigned);
|
|
57
|
+
if (record.sequence !== sequence || record.previousDigest !== previousDigest || digest !== sha(canonical(unsigned)) || !/^[0-9a-f]{64}$/.test(signature ?? '')
|
|
58
|
+
|| !timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex')) || record.state.schemaVersion !== BUDGET_SCHEMA
|
|
59
|
+
|| record.state.flowId !== location.flowId || record.state.repositoryDigest !== location.repositoryDigest) fail('ENTROROVE_BUDGET_CHAIN_INVALID', 'budget journal hash, signature, or task binding is invalid');
|
|
60
|
+
}
|
|
61
|
+
export async function loadBudget(location, optional = false) {
|
|
62
|
+
let names;
|
|
63
|
+
try { names = (await fs.readdir(location.journal)).filter(name => /^\d{12}\.json$/.test(name)).sort(); } catch (error) {
|
|
64
|
+
if (optional && error.code === 'ENOENT') {
|
|
65
|
+
for (const file of [location.file, location.keyFile, location.authorityFile]) { try { await fs.lstat(file); fail('ENTROROVE_BUDGET_CHAIN_INVALID', 'budget journal is missing for an existing budget'); } catch (cause) { if (cause.code !== 'ENOENT') throw cause; } }
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
if (!names.length) fail('ENTROROVE_BUDGET_CHAIN_INVALID', 'budget journal is empty');
|
|
71
|
+
await privateDirectory(location.journal, false); await privateDirectory(location.authorityDirectory, false);
|
|
72
|
+
const key = await regularBytes(location.keyFile); let previousDigest = null; let latest;
|
|
73
|
+
let anchor;
|
|
74
|
+
try { anchor = await json(location.authorityFile); } catch { fail('ENTROROVE_BUDGET_CHAIN_INVALID', 'budget integrity head is missing or unreadable'); }
|
|
75
|
+
const { signature: anchorSignature, ...anchorUnsigned } = anchor;
|
|
76
|
+
if (anchor.flowId !== location.flowId || anchor.repositoryDigest !== location.repositoryDigest || anchorSignature !== mac(key, anchorUnsigned)
|
|
77
|
+
|| !Number.isSafeInteger(anchor.sequence) || anchor.sequence < 0) fail('ENTROROVE_BUDGET_CHAIN_INVALID', 'budget integrity head is invalid');
|
|
78
|
+
let anchorMatched = anchor.sequence === 0 && anchor.head === null;
|
|
79
|
+
for (const [index, name] of names.entries()) {
|
|
80
|
+
const record = await json(path.join(location.journal, name)); verifyRecord(record, key, index + 1, previousDigest, location);
|
|
81
|
+
if (latest && record.atMs < latest.atMs) fail('ENTROROVE_BUDGET_CLOCK', 'budget wall clock moved backwards');
|
|
82
|
+
previousDigest = record.digest; latest = record;
|
|
83
|
+
if (record.sequence === anchor.sequence && record.digest === anchor.head) anchorMatched = true;
|
|
84
|
+
}
|
|
85
|
+
if (!anchorMatched || latest.sequence < anchor.sequence || latest.sequence > anchor.sequence + 1) fail('ENTROROVE_BUDGET_CHAIN_INVALID', 'budget journal was truncated or diverged from its independent integrity head');
|
|
86
|
+
let cache;
|
|
87
|
+
try { cache = await json(location.file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
88
|
+
if (cache) {
|
|
89
|
+
const record = await json(path.join(location.journal, `${String(cache.ledger?.sequence).padStart(12, '0')}.json`)).catch(() => null);
|
|
90
|
+
if (!record || canonical(cache) !== canonical({ ...record.state, ledger: { sequence: record.sequence, head: record.digest } })) fail('ENTROROVE_BUDGET_CHAIN_INVALID', 'budget cache does not match a committed journal record');
|
|
91
|
+
}
|
|
92
|
+
const state = { ...latest.state, ledger: { sequence: latest.sequence, head: latest.digest } };
|
|
93
|
+
if (state.enforced) verifyBudgetGrant(state.grant, location, state.grant.kind, false);
|
|
94
|
+
return { state, key, latest, anchor };
|
|
95
|
+
}
|
|
96
|
+
async function writeAnchor(location, key, sequence, head) {
|
|
97
|
+
await privateDirectory(location.authorityDirectory);
|
|
98
|
+
const unsigned = { schemaVersion: 'entrorove.budget-authority/1.0', flowId: location.flowId, repositoryDigest: location.repositoryDigest, sequence, head };
|
|
99
|
+
const temporary = `${location.authorityFile}.${randomUUID()}.tmp`;
|
|
100
|
+
await durableWrite(temporary, `${canonical({ ...unsigned, signature: mac(key, unsigned) })}\n`);
|
|
101
|
+
await fs.rename(temporary, location.authorityFile); await syncDirectory(location.authorityDirectory);
|
|
102
|
+
}
|
|
103
|
+
export async function commitBudget(location, loaded, state, event, data = {}) {
|
|
104
|
+
await privateDirectory(location.journal);
|
|
105
|
+
const key = loaded?.key ?? randomBytes(32);
|
|
106
|
+
if (!loaded) {
|
|
107
|
+
await privateDirectory(location.authorityDirectory); await durableWrite(location.keyFile, key);
|
|
108
|
+
await writeAnchor(location, key, 0, null);
|
|
109
|
+
} else if (loaded.anchor.sequence < loaded.latest.sequence) {
|
|
110
|
+
await writeAnchor(location, key, loaded.latest.sequence, loaded.latest.digest);
|
|
111
|
+
}
|
|
112
|
+
const { ledger, ...plain } = state;
|
|
113
|
+
const atMs = Date.now();
|
|
114
|
+
if (loaded && atMs < loaded.latest.atMs) fail('ENTROROVE_BUDGET_CLOCK', 'budget wall clock moved backwards');
|
|
115
|
+
const unsigned = { sequence: (loaded?.state.ledger.sequence ?? 0) + 1, previousDigest: loaded?.state.ledger.head ?? null, event, atMs, data, state: plain };
|
|
116
|
+
const record = { ...unsigned, digest: sha(canonical(unsigned)), signature: mac(key, unsigned) };
|
|
117
|
+
const destination = path.join(location.journal, `${String(record.sequence).padStart(12, '0')}.json`);
|
|
118
|
+
const temporaryRecord = `${destination}.${randomUUID()}.tmp`;
|
|
119
|
+
await durableWrite(temporaryRecord, `${canonical(record)}\n`);
|
|
120
|
+
try { await fs.link(temporaryRecord, destination); await syncDirectory(location.journal); } finally { await fs.unlink(temporaryRecord).catch(() => undefined); }
|
|
121
|
+
await writeAnchor(location, key, record.sequence, record.digest);
|
|
122
|
+
const updated = { ...plain, ledger: { sequence: record.sequence, head: record.digest } };
|
|
123
|
+
const temporaryCache = `${location.file}.${randomUUID()}.tmp`;
|
|
124
|
+
await durableWrite(temporaryCache, `${canonical(updated)}\n`);
|
|
125
|
+
await fs.rename(temporaryCache, location.file); await syncDirectory(location.directory);
|
|
126
|
+
return updated;
|
|
127
|
+
}
|
|
128
|
+
export async function withBudget(input, action, optional = false) {
|
|
129
|
+
const location = await budgetLocation(input, optional); await privateDirectory(location.directory);
|
|
130
|
+
return withBudgetLock(location.file, async () => action(location, await loadBudget(location, optional)));
|
|
131
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { canonical, fail, requireLiveBudget, READ_BUDGETS, verifyBudgetGrant } from './local-budget-grants.mjs';
|
|
2
|
+
import { withBudget, commitBudget } from './local-budget-store.mjs';
|
|
3
|
+
import { budgetView } from './local-budget.mjs';
|
|
4
|
+
|
|
5
|
+
function retainedLimits(state, mode) {
|
|
6
|
+
const total = { ...READ_BUDGETS[mode] };
|
|
7
|
+
for (const extension of state.extensions) {
|
|
8
|
+
total.maxFiles += extension.additions.files;
|
|
9
|
+
total.maxTokenEstimate += extension.additions.tokenEstimate;
|
|
10
|
+
total.maxDurationMs += extension.additions.durationMs;
|
|
11
|
+
}
|
|
12
|
+
if (Object.values(total).some(value => !Number.isSafeInteger(value))) fail('ENTROROVE_EXTENSION_INVALID', 'retained budget additions overflow safe integer limits');
|
|
13
|
+
return total;
|
|
14
|
+
}
|
|
15
|
+
function validateTransition(state, grant) {
|
|
16
|
+
const original = state.initialGrant; const transition = grant.transition;
|
|
17
|
+
if (state.grant.purpose !== 'scope-bootstrap' || original.purpose !== 'scope-bootstrap'
|
|
18
|
+
|| !original.bootstrap || original.bootstrap.candidateRevision !== 1 || grant.purpose !== 'accepted-scope'
|
|
19
|
+
|| grant.previousGrantId !== state.grant.grantId || grant.revision !== state.revision + 1
|
|
20
|
+
|| grant.expiresAt !== original.expiresAt || grant.scope.goal !== original.scope.goal
|
|
21
|
+
|| !transition || transition.candidateScopeDigest !== original.scopeDigest
|
|
22
|
+
|| transition.acceptedScopeDigest !== grant.scopeDigest
|
|
23
|
+
|| transition.candidateRequestId !== original.bootstrap.candidateRequestId || transition.candidateRevision !== 1
|
|
24
|
+
|| canonical(grant.bootstrap) !== canonical(original.bootstrap)
|
|
25
|
+
|| canonical(grant.limits) !== canonical(retainedLimits(state, grant.mode)) || grant.additions || grant.policy
|
|
26
|
+
|| (original.scope.forbiddenPaths ?? []).some(forbidden => !grant.scope.forbiddenPaths?.some(path => forbidden === path || forbidden.startsWith(`${path}/`)))) {
|
|
27
|
+
fail('ENTROROVE_CONFIRMATION_BINDING', 'scope transition must retain its original candidate lease, goal, prohibitions, additions and cumulative allowance');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function budgetTransition(input) {
|
|
31
|
+
return withBudget(input, async (location, loaded) => {
|
|
32
|
+
const state = loaded.state; requireLiveBudget(state);
|
|
33
|
+
if (!state.enforced) fail('ENTROROVE_BUDGET_NOT_APPLICABLE', 'scope transition applies only to a cloud sandbox');
|
|
34
|
+
if (state.transitions?.some(item => item.grantId === input.grant?.grantId)) fail('ENTROROVE_CONFIRMATION_REPLAYED', 'scope transition grant was already consumed');
|
|
35
|
+
const binding = { ...state.grant, mode: undefined, scopeDigest: undefined };
|
|
36
|
+
const grant = verifyBudgetGrant(input.grant, binding, 'transition');
|
|
37
|
+
validateTransition(state, grant);
|
|
38
|
+
if (state.autoRenew?.status === 'authorized' || state.autoRenew?.status === 'pending') fail('ENTROROVE_BUDGET_TRANSITION_REQUIRED', 'candidate reading cannot have an automatic renewal authorization');
|
|
39
|
+
const updated = { ...state, ...grant.limits, scope: grant.scope, mode: grant.mode, grant, revision: grant.revision,
|
|
40
|
+
transitions: [...(state.transitions ?? []), { grantId: grant.grantId, requestId: grant.requestId, transition: grant.transition,
|
|
41
|
+
priorScopeDigest: state.grant.scopeDigest, scopeDigest: grant.scopeDigest, at: new Date().toISOString() }] };
|
|
42
|
+
return budgetView(await commitBudget(location, loaded, updated, 'budget-scope-transition', { grantId: grant.grantId,
|
|
43
|
+
previousGrantId: grant.previousGrantId, transition: grant.transition }));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export const LOCAL_BUDGET_TRANSITION_OPERATIONS = Object.freeze({ 'budget-transition': budgetTransition });
|
package/local-budget.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { BUDGET_SCHEMA, READ_BUDGETS, canonical, fail, relativePath, requireLiveBudget, validateAdditions, verifyBudgetGrant } from './local-budget-grants.mjs';
|
|
5
|
+
import { budgetLocation, loadBudget, withBudget, commitBudget } from './local-budget-store.mjs';
|
|
6
|
+
import { consumeBudgetRenewal } from './local-budget-renewal.mjs';
|
|
7
|
+
import { assertDependencyReadAllowed, assertBudgetReadContext } from './local-budget-reads.mjs';
|
|
8
|
+
import { assertBudgetHandoffReadAllowed, budgetHandoffReadValidationState } from './local-budget-handoff.mjs';
|
|
9
|
+
import { budgetSourceTarget } from './local-budget-source-bindings.mjs';
|
|
10
|
+
|
|
11
|
+
export { BUDGET_SCHEMA, READ_BUDGETS };
|
|
12
|
+
export { assertReadAllowed as assertBudgetSourceReadAllowed };
|
|
13
|
+
const readOutcomes = new WeakMap();
|
|
14
|
+
/** Private receipts exist only for the exact returned result or thrown error of an accounted read. */
|
|
15
|
+
export function getBudgetReadOutcome(value) { return readOutcomes.get(value); }
|
|
16
|
+
function retainReadOutcome(location, initial, current, relative, readId, received, value) {
|
|
17
|
+
if (!value || typeof value !== 'object') return;
|
|
18
|
+
const observed = received.bytes > 0 || received.complete;
|
|
19
|
+
if (observed && (current.latest.data.readId !== readId || current.state.readCalls !== initial.state.readCalls + 1
|
|
20
|
+
|| current.state.tokenEstimate !== initial.state.tokenEstimate + Math.ceil(received.bytes / 4))) return;
|
|
21
|
+
const state = current.state;
|
|
22
|
+
readOutcomes.set(value, Object.freeze({ flowId: location.flowId, repositoryRoot: location.root, budgetHome: location.home,
|
|
23
|
+
path: relative, readId, initialSequence: initial.latest.sequence, initialHead: initial.latest.digest,
|
|
24
|
+
sequence: current.latest.sequence, head: current.latest.digest, readCalls: state.readCalls,
|
|
25
|
+
tokenEstimate: state.tokenEstimate, files: Object.freeze([...state.files]), bytes: received.bytes, complete: received.complete }));
|
|
26
|
+
}
|
|
27
|
+
function enforced(context) {
|
|
28
|
+
if (!context || typeof context !== 'object' || Array.isArray(context)) fail('ENTROROVE_CONTEXT_REQUIRED', 'executionContext is required');
|
|
29
|
+
if (Object.keys(context).length === 1 && context.executionIsolation === 'local') return false;
|
|
30
|
+
if (Object.keys(context).length !== 2 || !['direct', 'sandbox'].includes(context.executionIsolation) || typeof context.cloudSandboxEnabled !== 'boolean'
|
|
31
|
+
|| context.executionIsolation === 'sandbox' && !context.cloudSandboxEnabled) fail('ENTROROVE_CONTEXT_INVALID', 'executionContext is invalid');
|
|
32
|
+
return context.executionIsolation === 'sandbox';
|
|
33
|
+
}
|
|
34
|
+
export function budgetView(state, now = Date.now()) {
|
|
35
|
+
const elapsedMs = Math.max(0, now - Date.parse(state.startedAt));
|
|
36
|
+
const auto = state.autoRenew;
|
|
37
|
+
const intervals = state.enforced && elapsedMs >= state.maxDurationMs && auto?.status === 'authorized'
|
|
38
|
+
? Math.floor((elapsedMs - state.maxDurationMs) / auto.policy.intervalMs) + 1 : 0;
|
|
39
|
+
const autoRenewEligible = Boolean(intervals && !state.closedReason && now < Date.parse(state.grant.expiresAt)
|
|
40
|
+
&& auto.renewalsUsed + intervals <= auto.policy.maxRenewals);
|
|
41
|
+
return { ...state, elapsedMs, remainingFiles: state.enforced ? Math.max(0, state.maxFiles - state.files.length) : null,
|
|
42
|
+
remainingTokenEstimate: state.enforced ? Math.max(0, state.maxTokenEstimate - state.tokenEstimate) : null,
|
|
43
|
+
remainingDurationMs: state.enforced ? Math.max(0, state.maxDurationMs - elapsedMs) : null,
|
|
44
|
+
enforcement: state.enforced ? 'cloud-sandbox' : 'continuous', autoRenewEligible,
|
|
45
|
+
decisionRequired: state.enforced && elapsedMs >= state.maxDurationMs && !autoRenewEligible,
|
|
46
|
+
nextActions: state.closedReason ? [] : autoRenewEligible ? ['budget-read'] : [] };
|
|
47
|
+
}
|
|
48
|
+
export async function budgetInit(input) {
|
|
49
|
+
if (input.grant?.purpose === 'handoff-read' || input.grant?.kind === 'handoff-restore') return (await import('./local-budget-handoff.mjs')).budgetHandoff(input);
|
|
50
|
+
if (!READ_BUDGETS[input.mode]) fail('ENTROROVE_MODE_INVALID', 'mode must be lock, probe, or swarm');
|
|
51
|
+
const isEnforced = enforced(input.executionContext);
|
|
52
|
+
return withBudget(input, async (location, loaded) => {
|
|
53
|
+
if (loaded) {
|
|
54
|
+
if (loaded.state.mode !== input.mode || canonical(loaded.state.executionContext) !== canonical(input.executionContext)) fail('ENTROROVE_BUDGET_REINITIALIZE', 'budget is already initialized with a different scope');
|
|
55
|
+
if (isEnforced && canonical(input.grant) !== canonical(loaded.state.initialGrant)) fail('ENTROROVE_BUDGET_REINITIALIZE', 'budget original cloud grant cannot be replaced');
|
|
56
|
+
requireLiveBudget(loaded.state); return budgetView(loaded.state);
|
|
57
|
+
}
|
|
58
|
+
const grant = isEnforced ? verifyBudgetGrant(input.grant, location, 'init') : null;
|
|
59
|
+
if (grant && (grant.revision !== 1 || grant.previousGrantId !== null || grant.mode !== input.mode
|
|
60
|
+
|| canonical(grant.limits) !== canonical(READ_BUDGETS[input.mode]))) fail('ENTROROVE_BUDGET_GRANT_INVALID', 'initial grant must use the original mode limits and revision');
|
|
61
|
+
if (grant?.purpose === 'scope-bootstrap' && (!grant.bootstrap || grant.bootstrap.candidateRevision !== 1
|
|
62
|
+
|| typeof grant.bootstrap.candidateRequestId !== 'string')) fail('ENTROROVE_BUDGET_GRANT_INVALID', 'candidate budget must retain its original exact cloud scope confirmation');
|
|
63
|
+
const state = { schemaVersion: BUDGET_SCHEMA, flowId: location.flowId, repositoryDigest: location.repositoryDigest, mode: input.mode,
|
|
64
|
+
executionContext: input.executionContext, enforced: isEnforced,
|
|
65
|
+
startedAt: grant?.purpose === 'scope-bootstrap' ? new Date(grant.issuedAt).toISOString() : new Date().toISOString(), elapsedMs: 0,
|
|
66
|
+
...READ_BUDGETS[input.mode], files: [], readCalls: 0, tokenEstimate: 0, extensions: [], grant, initialGrant: grant,
|
|
67
|
+
scope: grant?.scope ?? null, revision: grant?.revision ?? 1, closedReason: null, autoRenew: null };
|
|
68
|
+
return budgetView(await commitBudget(location, null, state, 'budget-init', { grantId: grant?.grantId ?? null }));
|
|
69
|
+
}, true);
|
|
70
|
+
}
|
|
71
|
+
export async function budgetContext(input) {
|
|
72
|
+
return withBudget(input, async (location, loaded) => {
|
|
73
|
+
const state = loaded.state; const isEnforced = enforced(input.executionContext);
|
|
74
|
+
if (state.enforced && !isEnforced) fail('ENTROROVE_BUDGET_DOWNGRADE', 'an enforced sandbox budget cannot be downgraded');
|
|
75
|
+
if (isEnforced && !state.enforced) fail('ENTROROVE_BUDGET_GRANT_REQUIRED', 'sandbox entry needs a new cloud-bound budget task');
|
|
76
|
+
requireLiveBudget(state);
|
|
77
|
+
if (canonical(state.executionContext) === canonical(input.executionContext)) return budgetView(state);
|
|
78
|
+
return budgetView(await commitBudget(location, loaded, { ...state, executionContext: input.executionContext }, 'budget-context'));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export async function budgetStatus(input) {
|
|
82
|
+
return withBudget(input, async (location, loaded) => budgetView(loaded.state));
|
|
83
|
+
}
|
|
84
|
+
function assertReadAllowed(state, relative, tokens, now = Date.now()) {
|
|
85
|
+
requireLiveBudget(budgetHandoffReadValidationState(state), now);
|
|
86
|
+
if (!state.enforced) return;
|
|
87
|
+
if (state.scope.forbiddenPaths?.some(scope => relative === scope || relative.startsWith(`${scope}/`))) fail('ENTROROVE_BUDGET_SCOPE', 'source read is forbidden by the accepted cloud task scope');
|
|
88
|
+
if (!state.scope.allowedPaths.some(scope => relative === scope || relative.startsWith(`${scope}/`))) fail('ENTROROVE_BUDGET_SCOPE', 'source read is outside the original cloud task scope');
|
|
89
|
+
if (!state.files.includes(relative) && state.files.length >= state.maxFiles) fail('ENTROROVE_BUDGET_EXHAUSTED', 'read file budget exhausted');
|
|
90
|
+
if (tokens > state.maxTokenEstimate - state.tokenEstimate) fail('ENTROROVE_BUDGET_EXHAUSTED', 'read token budget exhausted');
|
|
91
|
+
if (now - Date.parse(state.startedAt) >= state.maxDurationMs) fail('ENTROROVE_BUDGET_EXHAUSTED', 'read duration budget exhausted');
|
|
92
|
+
}
|
|
93
|
+
async function readSource(location, state, relative, target, received) {
|
|
94
|
+
const binding = { home: location.home, repositoryRoot: location.root, flowId: location.flowId, path: relative };
|
|
95
|
+
await assertBudgetHandoffReadAllowed(binding, state);
|
|
96
|
+
const actualTarget = await budgetSourceTarget(location, relative, target);
|
|
97
|
+
let handle;
|
|
98
|
+
try { handle = await fs.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); }
|
|
99
|
+
catch (error) { if (error.code === 'ELOOP') fail('ENTROROVE_FILE_INVALID', 'budget source must be a regular file'); throw error; }
|
|
100
|
+
try {
|
|
101
|
+
const stat = await handle.stat(); if (!stat.isFile()) fail('ENTROROVE_FILE_INVALID', 'budget source must be a regular file');
|
|
102
|
+
await assertDescriptorTarget(target, actualTarget, stat);
|
|
103
|
+
assertReadAllowed(state, relative, Math.ceil(stat.size / 4));
|
|
104
|
+
const cap = state.enforced ? Math.max(0, (state.maxTokenEstimate - state.tokenEstimate) * 4) : Number.MAX_SAFE_INTEGER;
|
|
105
|
+
const chunks = []; let size = 0;
|
|
106
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) {
|
|
107
|
+
size += chunk.length; received.bytes = size;
|
|
108
|
+
if (size > cap) fail('ENTROROVE_BUDGET_EXHAUSTED', 'read token budget exhausted');
|
|
109
|
+
assertReadAllowed(state, relative, Math.ceil(size / 4));
|
|
110
|
+
await assertBudgetHandoffReadAllowed(binding, state); chunks.push(chunk);
|
|
111
|
+
}
|
|
112
|
+
received.complete = true;
|
|
113
|
+
await assertDescriptorTarget(target, actualTarget, stat);
|
|
114
|
+
assertReadAllowed(state, relative, Math.ceil(size / 4));
|
|
115
|
+
await assertBudgetHandoffReadAllowed(binding, state); return Buffer.concat(chunks, size);
|
|
116
|
+
} finally { await handle.close(); }
|
|
117
|
+
}
|
|
118
|
+
async function assertDescriptorTarget(target, expected, descriptorStat) {
|
|
119
|
+
const current = await fs.realpath(target); const stat = await fs.stat(current);
|
|
120
|
+
if (current !== expected || stat.dev !== descriptorStat.dev || stat.ino !== descriptorStat.ino) fail('ENTROROVE_PATH_ESCAPE', 'source path changed while its budgeted descriptor was open');
|
|
121
|
+
}
|
|
122
|
+
export async function budgetReadBuffer(input, options = {}) {
|
|
123
|
+
await assertBudgetReadContext(input);
|
|
124
|
+
const relative = relativePath(input.path);
|
|
125
|
+
return withBudget(input, async (location, loaded) => {
|
|
126
|
+
const received = { bytes: 0, complete: false }, readId = randomUUID();
|
|
127
|
+
let state = loaded.state, charged = false;
|
|
128
|
+
try {
|
|
129
|
+
await assertDependencyReadAllowed(input);
|
|
130
|
+
if (state.grant?.purpose === 'handoff-read') requireLiveBudget(budgetHandoffReadValidationState(state));
|
|
131
|
+
else state = consumeBudgetRenewal({ state });
|
|
132
|
+
const target = options.target ?? path.join(location.root, relative);
|
|
133
|
+
const content = await readSource(location, state, relative, target, received); const tokens = Math.ceil(content.length / 4);
|
|
134
|
+
const updated = { ...state, files: state.files.includes(relative) ? state.files : [...state.files, relative], readCalls: state.readCalls + 1, tokenEstimate: state.tokenEstimate + tokens };
|
|
135
|
+
const committed = await commitBudget(location, loaded, updated, 'budget-read', { path: relative, tokens, bytes: content.length,
|
|
136
|
+
readId, renewedIntervals: (state.autoRenew?.renewalsUsed ?? 0) - (loaded.state.autoRenew?.renewalsUsed ?? 0), reason: state.autoRenew?.lastRenewalReason ?? null });
|
|
137
|
+
charged = true;
|
|
138
|
+
assertReadAllowed(committed, relative, 0);
|
|
139
|
+
await assertBudgetHandoffReadAllowed(input, committed);
|
|
140
|
+
const result = { content, budget: budgetView(committed), path: relative };
|
|
141
|
+
retainReadOutcome(location, loaded, await loadBudget(location), relative, readId, received, result);
|
|
142
|
+
return result;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
readOutcomes.delete(error);
|
|
145
|
+
const retained = await loadBudget(location);
|
|
146
|
+
if (!charged && retained.latest.data.readId !== readId && (received.bytes || received.complete)) {
|
|
147
|
+
const tokens = Math.ceil(received.bytes / 4);
|
|
148
|
+
await commitBudget(location, retained, { ...state, files: state.files.includes(relative) ? state.files : [...state.files, relative],
|
|
149
|
+
readCalls: state.readCalls + 1, tokenEstimate: state.tokenEstimate + tokens }, 'budget-read',
|
|
150
|
+
{ path: relative, tokens, bytes: received.bytes, readId, readIncomplete: true,
|
|
151
|
+
renewedIntervals: (state.autoRenew?.renewalsUsed ?? 0) - (loaded.state.autoRenew?.renewalsUsed ?? 0), reason: state.autoRenew?.lastRenewalReason ?? null });
|
|
152
|
+
}
|
|
153
|
+
if (error.code?.startsWith('ENTROROVE_') && error.code !== 'ENTROROVE_BUDGET_CHAIN_INVALID') {
|
|
154
|
+
const current = await loadBudget(location);
|
|
155
|
+
await commitBudget(location, current, current.state, 'budget-read-denied', { path: relative, code: error.code, readId });
|
|
156
|
+
}
|
|
157
|
+
retainReadOutcome(location, loaded, await loadBudget(location), relative, readId, received, error);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
export async function budgetRead(input) {
|
|
163
|
+
const read = await budgetReadBuffer(input);
|
|
164
|
+
return { schemaVersion: 'entrorove.local-execution/1.0', path: read.path, content: read.content.toString('utf8'), budget: read.budget };
|
|
165
|
+
}
|
|
166
|
+
export async function budgetExtend(input) {
|
|
167
|
+
return withBudget(input, async (location, loaded) => {
|
|
168
|
+
const state = loaded.state;
|
|
169
|
+
if (!state.enforced) fail('ENTROROVE_BUDGET_NOT_APPLICABLE', 'budget extension applies only to a cloud sandbox');
|
|
170
|
+
if (state.grant.purpose === 'handoff-read') fail('ENTROROVE_BUDGET_HANDOFF_FORBIDDEN', 'temporary handoff reads cannot extend their source authority');
|
|
171
|
+
requireLiveBudget(state);
|
|
172
|
+
const grant = verifyBudgetGrant(input.grant?.kind === 'extend' ? input.grant : input.confirmation?.grant, state.grant, 'extend');
|
|
173
|
+
const additions = validateAdditions(input.additions);
|
|
174
|
+
if (state.extensions.some(item => item.grantId === grant.grantId)) fail('ENTROROVE_CONFIRMATION_REPLAYED', 'budget grant was already applied');
|
|
175
|
+
if (grant.previousGrantId !== state.grant.grantId || grant.revision !== state.revision + 1 || canonical(grant.additions) !== canonical(additions)
|
|
176
|
+
|| canonical(grant.limits) !== canonical({ maxFiles: state.maxFiles + additions.files, maxTokenEstimate: state.maxTokenEstimate + additions.tokenEstimate,
|
|
177
|
+
maxDurationMs: state.maxDurationMs - (state.autoRenew?.cumulativeDurationMs ?? 0) + additions.durationMs })
|
|
178
|
+
|| grant.expiresAt !== state.grant.expiresAt) fail('ENTROROVE_CONFIRMATION_BINDING', 'extension does not match the original budget revision, quota, and lease');
|
|
179
|
+
const updated = { ...state, maxFiles: state.maxFiles + additions.files, maxTokenEstimate: state.maxTokenEstimate + additions.tokenEstimate,
|
|
180
|
+
maxDurationMs: state.maxDurationMs + additions.durationMs, revision: grant.revision, grant,
|
|
181
|
+
extensions: [...state.extensions, { grantId: grant.grantId, requestId: grant.requestId, additions, at: new Date().toISOString() }] };
|
|
182
|
+
if (['maxFiles', 'maxTokenEstimate', 'maxDurationMs'].some(field => !Number.isSafeInteger(updated[field]))) fail('ENTROROVE_EXTENSION_INVALID', 'budget extension overflows safe integer limits');
|
|
183
|
+
return budgetView(await commitBudget(location, loaded, updated, 'budget-extend', { grantId: grant.grantId, additions }));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { runCalctoolLocal } from './local-calctool.mjs';
|
|
4
|
+
import { CALCTOOL_LOCAL_OPERATION_SCHEMAS } from './local-calctool-contracts.mjs';
|
|
5
|
+
|
|
6
|
+
export async function runCalctoolCli(args = process.argv.slice(2)) {
|
|
7
|
+
let source = ''; for await (const chunk of process.stdin) source += chunk;
|
|
8
|
+
let input;
|
|
9
|
+
try { input = source.trim() ? JSON.parse(source) : {}; }
|
|
10
|
+
catch { throw Object.assign(new Error('Calctool stdin must be valid JSON'), { code: 'CALCTOOL_JSON_INVALID' }); }
|
|
11
|
+
if (args[0] === 'capabilities') return { schemaVersion: 'entrorove.local-execution/1.0', operationSchemas: CALCTOOL_LOCAL_OPERATION_SCHEMAS, operations: Object.keys(CALCTOOL_LOCAL_OPERATION_SCHEMAS) };
|
|
12
|
+
if (args[1] && input.repositoryRoot === undefined) input.repositoryRoot = args[1];
|
|
13
|
+
return runCalctoolLocal(args[0] ?? '', input);
|
|
14
|
+
}
|
|
15
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
16
|
+
try {
|
|
17
|
+
const result = await runCalctoolCli(); process.stdout.write(JSON.stringify(result) + '\n');
|
|
18
|
+
if (['blocked', 'failed', 'incomplete'].includes(result.status)) process.exitCode = 2;
|
|
19
|
+
} catch (cause) {
|
|
20
|
+
process.stderr.write(JSON.stringify({ status: 'blocked', code: cause.code ?? 'CALCTOOL_INPUT_INVALID', message: cause.message }) + '\n'); process.exitCode = 2;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { FINAL_GATE16_INPUT_SCHEMA, FINAL_GATE16_READ_INPUT_SCHEMA } from './local-final-gate16-contract.mjs';
|
|
2
|
+
import { array, identifier, integer, object, record, relativePath, sha256, text } from './local-contract-types.mjs';
|
|
3
|
+
|
|
4
|
+
export const CALCTOOL_LOCAL_NAMES = Object.freeze(['run', 'compile', 'generate', 'verify', 'estimate', 'impact', 'status', 'inventory', 'purge',
|
|
5
|
+
'brain-handshake', 'brain-invoke', 'brain-events', 'brain-cancel', 'brain-complete', 'brain-resume', 'brain-status',
|
|
6
|
+
'probe-env', 'adapt-config', 'intake-round', 'compile-tool', 'blueprint-orchestrate', 'auto-pipeline', 'final-gate', 'swarm-orchestrate']);
|
|
7
|
+
const base = { repositoryRoot: text, home: text, flowId: identifier, stepId: identifier };
|
|
8
|
+
const engineSource = { ...base, engine: record, enginePath: relativePath, requirements: record, program: record,
|
|
9
|
+
engineId: text, engineVersion: text, engineDigest: sha256, programDigest: sha256 };
|
|
10
|
+
const sources = ['engine', 'enginePath', 'requirements', 'program', 'engineId'];
|
|
11
|
+
function sourceSchema(properties = {}, required = []) {
|
|
12
|
+
return { ...object({ ...engineSource, ...properties }, required), oneOf: sources.map(field => ({ type: 'object', required: [field] })),
|
|
13
|
+
allOf: [{ if: { type: 'object', required: ['enginePath'] }, then: { type: 'object', required: ['repositoryRoot'] } },
|
|
14
|
+
{ if: { type: 'object', required: ['engineId'] }, then: { type: 'object', required: ['repositoryRoot', 'engineVersion'] } }] };
|
|
15
|
+
}
|
|
16
|
+
const schemas = {};
|
|
17
|
+
function add(name, schema) { schemas[`calctool-${name}`] = schema; }
|
|
18
|
+
add('run', sourceSchema({ inputs: { type: 'object', additionalProperties: { anyOf: [{ type: 'string' }, { type: 'number' }] } } }, ['inputs']));
|
|
19
|
+
add('compile', sourceSchema({}, ['repositoryRoot']));
|
|
20
|
+
add('generate', sourceSchema({}, ['repositoryRoot']));
|
|
21
|
+
add('compile-tool', sourceSchema({}, ['repositoryRoot']));
|
|
22
|
+
add('verify', sourceSchema());
|
|
23
|
+
add('final-gate', { oneOf: [sourceSchema(), FINAL_GATE16_INPUT_SCHEMA, FINAL_GATE16_READ_INPUT_SCHEMA] });
|
|
24
|
+
add('estimate', sourceSchema());
|
|
25
|
+
add('impact', sourceSchema({ changedKeys: array(text, 1) }, ['changedKeys']));
|
|
26
|
+
add('status', object({ ...base, engineId: text, engineVersion: text, programDigest: sha256 }, ['repositoryRoot', 'engineId', 'engineVersion']));
|
|
27
|
+
add('inventory', object(base, ['repositoryRoot']));
|
|
28
|
+
add('purge', object({ ...base, engineId: text, engineVersion: text, programDigest: sha256 }, ['repositoryRoot', 'engineId', 'engineVersion', 'programDigest']));
|
|
29
|
+
add('probe-env', object({}));
|
|
30
|
+
add('adapt-config', object({ probe: record }));
|
|
31
|
+
add('intake-round', object({ domainReference: record, message: { type: 'string' }, round: integer(0), confirmed: record }));
|
|
32
|
+
add('brain-handshake', object({ brainMode: { enum: ['ide', 'hermes_local'] }, swarmEnabled: { type: 'boolean' }, blueprintEnabled: { type: 'boolean' } }));
|
|
33
|
+
for (const name of ['brain-invoke', 'swarm-orchestrate']) add(name, object({ requirements: record }, ['requirements']));
|
|
34
|
+
add('auto-pipeline', object({ requirements: record, authorized: { type: 'boolean' }, skipDialogue: { type: 'boolean' }, domainReference: record }, ['requirements', 'authorized']));
|
|
35
|
+
const coordination = { runPlan: record, completedWorkItemIds: array(text), artifacts: record };
|
|
36
|
+
add('brain-status', object({ runPlan: record, completedWorkItemIds: array(text) }, ['runPlan']));
|
|
37
|
+
add('brain-resume', object({ runPlan: record, completedWorkItemIds: array(text), inFlightWorkItemIds: array(text), uncertainWorkItemIds: array(text) }, ['runPlan']));
|
|
38
|
+
add('brain-complete', object(coordination, ['runPlan', 'artifacts']));
|
|
39
|
+
add('brain-events', object({ events: { ...array(record), maxItems: 256 } }, ['events']));
|
|
40
|
+
add('brain-cancel', object({ workItemIds: array(text), reason: { type: 'string' } }, ['workItemIds']));
|
|
41
|
+
add('blueprint-orchestrate', sourceSchema({ goal: text }));
|
|
42
|
+
export const CALCTOOL_LOCAL_OPERATION_SCHEMAS = Object.freeze(schemas);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
const runtimeUrl = new URL('./runtime/calctool-runtime-v7.0.43.mjs', import.meta.url);
|
|
4
|
+
const expectedSha256 = '5d3506320d6baba4a8ca42cf55d062ae2b10f186741896c8cc93e536734f961f';
|
|
5
|
+
export async function calctoolRuntimeBytes() {
|
|
6
|
+
const bytes = await readFile(runtimeUrl);
|
|
7
|
+
if (createHash('sha256').update(bytes).digest('hex') !== expectedSha256) throw Object.assign(new Error('Calctool packaged runtime digest mismatch'), { code: 'FORMULA_RUNTIME_INTEGRITY' });
|
|
8
|
+
return bytes;
|
|
9
|
+
}
|
|
10
|
+
export async function loadCalctoolRuntime() { await calctoolRuntimeBytes(); return import(runtimeUrl.href); }
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { withStateLock } from './local-state-lock.mjs';
|
|
6
|
+
import { formulaDigest, formulaCanonical, verifyFormulaProgram } from './local-formula-engine.mjs';
|
|
7
|
+
import { formulaError } from './local-decimal.mjs';
|
|
8
|
+
|
|
9
|
+
const ID = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
|
10
|
+
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
11
|
+
async function realDirectory(directory, create) {
|
|
12
|
+
if (create) await fs.mkdir(directory, { mode: 0o700 }).catch(cause => { if (cause.code !== 'EEXIST') throw cause; });
|
|
13
|
+
const stat = await fs.lstat(directory);
|
|
14
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || await fs.realpath(directory) !== directory) throw formulaError('FORMULA_STATE_PATH', 'formula state directory must not contain symlinks');
|
|
15
|
+
}
|
|
16
|
+
export async function formulaStoreRoot(input, create = false) {
|
|
17
|
+
if (typeof input.repositoryRoot !== 'string' || !path.isAbsolute(input.repositoryRoot)) throw formulaError('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute');
|
|
18
|
+
const repositoryRoot = await fs.realpath(input.repositoryRoot);
|
|
19
|
+
const home = input.home ?? os.homedir();
|
|
20
|
+
if (typeof home !== 'string' || !path.isAbsolute(home)) throw formulaError('FORMULA_STATE_PATH', 'home must be absolute');
|
|
21
|
+
const normalized = await fs.realpath(home); let directory = normalized;
|
|
22
|
+
for (const component of ['.entrorove', 'calctool', formulaDigest(repositoryRoot)]) { directory = path.join(directory, component); await realDirectory(directory, create); }
|
|
23
|
+
return directory;
|
|
24
|
+
}
|
|
25
|
+
function identity(engineId, engineVersion) {
|
|
26
|
+
if (typeof engineId !== 'string' || !ID.test(engineId) || typeof engineVersion !== 'string' || !VERSION.test(engineVersion)) throw formulaError('FORMULA_ENGINE_IDENTITY', 'an explicit engineId and semantic engineVersion are required');
|
|
27
|
+
}
|
|
28
|
+
export async function readOwnedFormulaFile(file) {
|
|
29
|
+
const stat = await fs.lstat(file);
|
|
30
|
+
if (!stat.isFile() || stat.isSymbolicLink() || await fs.realpath(file) !== file) throw formulaError('FORMULA_STATE_PATH', 'formula artifact must be a regular file without symlinked ancestors');
|
|
31
|
+
return fs.readFile(file);
|
|
32
|
+
}
|
|
33
|
+
async function atomic(file, bytes) {
|
|
34
|
+
const temporary = `${file}.tmp-${randomUUID()}`;
|
|
35
|
+
try { await fs.writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 }); await fs.rename(temporary, file); }
|
|
36
|
+
finally { await fs.rm(temporary, { force: true }); }
|
|
37
|
+
}
|
|
38
|
+
async function parsed(file) {
|
|
39
|
+
try { return JSON.parse(await readOwnedFormulaFile(file)); }
|
|
40
|
+
catch (cause) { if (cause instanceof SyntaxError) throw formulaError('FORMULA_STATE_CORRUPT', 'formula cache JSON is invalid'); throw cause; }
|
|
41
|
+
}
|
|
42
|
+
async function checkedDirectory(input, create = false) {
|
|
43
|
+
identity(input.engineId, input.engineVersion); const root = await formulaStoreRoot(input, create);
|
|
44
|
+
const engineRoot = path.join(root, input.engineId); await realDirectory(engineRoot, create);
|
|
45
|
+
const directory = path.join(engineRoot, input.engineVersion); await realDirectory(directory, create);
|
|
46
|
+
return directory;
|
|
47
|
+
}
|
|
48
|
+
export async function loadStoredFormula(input) {
|
|
49
|
+
const directory = await checkedDirectory(input); const program = verifyFormulaProgram(await parsed(path.join(directory, 'program.json')), input);
|
|
50
|
+
const manifest = await parsed(path.join(directory, 'manifest.json'));
|
|
51
|
+
if (manifest.schemaVersion !== 'entrorove.formula-cache/1.0' || manifest.programDigest !== program.programDigest || manifest.engineDigest !== program.engineDigest
|
|
52
|
+
|| manifest.engineId !== program.engineId || manifest.engineVersion !== program.engineVersion || manifest.runtimeVersion !== program.runtimeVersion || !Array.isArray(manifest.files)) throw formulaError('FORMULA_STATE_CORRUPT', 'formula cache manifest binding is invalid');
|
|
53
|
+
const names = manifest.files.map(entry => entry.path).sort().join('|');
|
|
54
|
+
if (!['program.json', 'program.json|runtime.mjs|tool.mjs'].includes(names)) throw formulaError('FORMULA_STATE_CORRUPT', 'formula cache manifest must bind every generated file exactly once');
|
|
55
|
+
for (const entry of manifest.files) {
|
|
56
|
+
if (!['program.json', 'runtime.mjs', 'tool.mjs'].includes(entry.path) || formulaDigest(await readOwnedFormulaFile(path.join(directory, entry.path))) !== entry.sha256) throw formulaError('FORMULA_STATE_CORRUPT', 'formula cache file digest mismatch');
|
|
57
|
+
}
|
|
58
|
+
return { program, directory, manifest };
|
|
59
|
+
}
|
|
60
|
+
export async function storeFormula(input, program, toolFiles) {
|
|
61
|
+
verifyFormulaProgram(program);
|
|
62
|
+
const context = { ...input, engineId: program.engineId, engineVersion: program.engineVersion };
|
|
63
|
+
const root = await formulaStoreRoot(context, true);
|
|
64
|
+
return withStateLock(path.join(root, `${program.engineId}.state`), async () => {
|
|
65
|
+
const directory = await checkedDirectory(context, true); const file = path.join(directory, 'program.json');
|
|
66
|
+
try {
|
|
67
|
+
const existing = await loadStoredFormula(context);
|
|
68
|
+
if (existing.program.programDigest !== program.programDigest) throw formulaError('FORMULA_VERSION_CONFLICT', 'an engine version cannot be replaced by different formulas; increment semanticVersion');
|
|
69
|
+
if (!toolFiles || existing.manifest.files.length > 1) return { ...existing, reused: true };
|
|
70
|
+
} catch (cause) { if (cause.code !== 'ENOENT') throw cause; }
|
|
71
|
+
const entries = [{ path: 'program.json', bytes: Buffer.from(`${formulaCanonical(program)}\n`) }, ...(toolFiles ?? [])];
|
|
72
|
+
for (const entry of entries) await atomic(path.join(directory, entry.path), entry.bytes);
|
|
73
|
+
const manifest = { schemaVersion: 'entrorove.formula-cache/1.0', engineId: program.engineId, engineVersion: program.engineVersion,
|
|
74
|
+
engineDigest: program.engineDigest, programDigest: program.programDigest, runtimeVersion: program.runtimeVersion,
|
|
75
|
+
files: entries.map(entry => ({ path: entry.path, sha256: formulaDigest(entry.bytes) })) };
|
|
76
|
+
await atomic(path.join(directory, 'manifest.json'), Buffer.from(`${formulaCanonical(manifest)}\n`));
|
|
77
|
+
return { program, directory, manifest, reused: false };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export async function formulaInventory(input) {
|
|
81
|
+
let root;
|
|
82
|
+
try { root = await formulaStoreRoot(input); } catch (cause) { if (cause.code === 'ENOENT') return []; throw cause; }
|
|
83
|
+
const entries = [];
|
|
84
|
+
for (const engine of await fs.readdir(root, { withFileTypes: true })) {
|
|
85
|
+
if (!ID.test(engine.name) || engine.name.endsWith('.lock') || engine.name.endsWith('.state')) continue;
|
|
86
|
+
if (!engine.isDirectory() || engine.isSymbolicLink()) throw formulaError('FORMULA_STATE_PATH', 'formula cache engine directory is invalid');
|
|
87
|
+
for (const version of await fs.readdir(path.join(root, engine.name), { withFileTypes: true })) {
|
|
88
|
+
if (!VERSION.test(version.name) || !version.isDirectory() || version.isSymbolicLink()) throw formulaError('FORMULA_STATE_PATH', 'formula cache version directory is invalid');
|
|
89
|
+
const loaded = await loadStoredFormula({ ...input, engineId: engine.name, engineVersion: version.name }); entries.push(loaded.manifest);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return entries.sort((a, b) => `${a.engineId}/${a.engineVersion}` < `${b.engineId}/${b.engineVersion}` ? -1 : 1);
|
|
93
|
+
}
|
|
94
|
+
export async function purgeStoredFormula(input) {
|
|
95
|
+
if (!/^[0-9a-f]{64}$/.test(input.programDigest ?? '')) throw formulaError('FORMULA_PURGE_BINDING', 'purge requires the exact compiled program digest');
|
|
96
|
+
const root = await formulaStoreRoot(input); identity(input.engineId, input.engineVersion);
|
|
97
|
+
return withStateLock(path.join(root, `${input.engineId}.state`), async () => {
|
|
98
|
+
const loaded = await loadStoredFormula(input); await fs.rm(loaded.directory, { recursive: true });
|
|
99
|
+
return { purged: true, engineId: input.engineId, engineVersion: input.engineVersion, programDigest: loaded.program.programDigest };
|
|
100
|
+
});
|
|
101
|
+
}
|