@dsh-enhanced/assistant-actions 0.1.30
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/LICENSE +21 -0
- package/README.md +247 -0
- package/cordis.patch.yml +3 -0
- package/lib/broker-cli.d.ts +70 -0
- package/lib/broker-cli.d.ts.map +1 -0
- package/lib/broker-cli.js +255 -0
- package/lib/broker-cli.js.map +1 -0
- package/lib/broker-client.d.ts +54 -0
- package/lib/broker-client.d.ts.map +1 -0
- package/lib/broker-client.js +266 -0
- package/lib/broker-client.js.map +1 -0
- package/lib/broker-core.d.ts +51 -0
- package/lib/broker-core.d.ts.map +1 -0
- package/lib/broker-core.js +441 -0
- package/lib/broker-core.js.map +1 -0
- package/lib/broker-ledger.d.ts +131 -0
- package/lib/broker-ledger.d.ts.map +1 -0
- package/lib/broker-ledger.js +925 -0
- package/lib/broker-ledger.js.map +1 -0
- package/lib/broker-peer-linux.d.ts +23 -0
- package/lib/broker-peer-linux.d.ts.map +1 -0
- package/lib/broker-peer-linux.js +68 -0
- package/lib/broker-peer-linux.js.map +1 -0
- package/lib/broker-protocol.d.ts +419 -0
- package/lib/broker-protocol.d.ts.map +1 -0
- package/lib/broker-protocol.js +786 -0
- package/lib/broker-protocol.js.map +1 -0
- package/lib/broker-server.d.ts +77 -0
- package/lib/broker-server.d.ts.map +1 -0
- package/lib/broker-server.js +467 -0
- package/lib/broker-server.js.map +1 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +36 -0
- package/lib/cli.js.map +1 -0
- package/lib/config.d.ts +54 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +165 -0
- package/lib/config.js.map +1 -0
- package/lib/external-delivery-receipt.d.ts +11 -0
- package/lib/external-delivery-receipt.d.ts.map +1 -0
- package/lib/external-delivery-receipt.js +25 -0
- package/lib/external-delivery-receipt.js.map +1 -0
- package/lib/github.d.ts +78 -0
- package/lib/github.d.ts.map +1 -0
- package/lib/github.js +611 -0
- package/lib/github.js.map +1 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +23 -0
- package/lib/index.js.map +1 -0
- package/lib/ledger.d.ts +78 -0
- package/lib/ledger.d.ts.map +1 -0
- package/lib/ledger.js +1064 -0
- package/lib/ledger.js.map +1 -0
- package/lib/repository-readback.d.ts +34 -0
- package/lib/repository-readback.d.ts.map +1 -0
- package/lib/repository-readback.js +193 -0
- package/lib/repository-readback.js.map +1 -0
- package/lib/service.d.ts +86 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +1259 -0
- package/lib/service.js.map +1 -0
- package/lib/types.d.ts +174 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/verified-delivery.d.ts +104 -0
- package/lib/verified-delivery.d.ts.map +1 -0
- package/lib/verified-delivery.js +262 -0
- package/lib/verified-delivery.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/package.json +123 -0
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, closeSync, constants, lstatSync, mkdirSync, openSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute } from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
const schemaVersion = 3;
|
|
6
|
+
const maxRecords = 10_000;
|
|
7
|
+
const maxActiveActions = 2;
|
|
8
|
+
export class ActionLedgerError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code) {
|
|
11
|
+
super(`action ledger rejected: ${code}`);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = 'ActionLedgerError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function fail(code) { throw new ActionLedgerError(code); }
|
|
17
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
18
|
+
const equal = (left, right) => stableJson(left) === stableJson(right);
|
|
19
|
+
function plain(value) {
|
|
20
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype && Object.getOwnPropertySymbols(value).length === 0;
|
|
21
|
+
}
|
|
22
|
+
function object(value, required, optional = []) {
|
|
23
|
+
if (!plain(value))
|
|
24
|
+
fail('invalid-input');
|
|
25
|
+
const names = Object.getOwnPropertyNames(value).sort();
|
|
26
|
+
const allowed = [...required, ...optional].sort();
|
|
27
|
+
if (names.length < required.length || names.some(name => !allowed.includes(name)) || required.some(name => !own(value, name)))
|
|
28
|
+
fail('invalid-input');
|
|
29
|
+
for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value)))
|
|
30
|
+
if (!('value' in descriptor) || !descriptor.enumerable)
|
|
31
|
+
fail('invalid-input');
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function array(value, maximum) {
|
|
35
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || Object.getOwnPropertySymbols(value).length !== 0 || value.length > maximum)
|
|
36
|
+
fail('invalid-input');
|
|
37
|
+
const names = Object.getOwnPropertyNames(value);
|
|
38
|
+
if (names.length !== value.length + 1 || names.at(-1) !== 'length')
|
|
39
|
+
fail('invalid-input');
|
|
40
|
+
for (let index = 0; index < value.length; index++) {
|
|
41
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
42
|
+
if (!descriptor || !('value' in descriptor) || !descriptor.enumerable)
|
|
43
|
+
fail('invalid-input');
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function text(value, maximum = 4_096, minimum = 1) {
|
|
48
|
+
if (typeof value !== 'string' || value.length < minimum || value.length > maximum || value.includes('\0'))
|
|
49
|
+
fail('invalid-input');
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function integer(value, minimum, maximum = Number.MAX_SAFE_INTEGER) {
|
|
53
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum)
|
|
54
|
+
fail('invalid-input');
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function after(now, duration) {
|
|
58
|
+
if (duration > Number.MAX_SAFE_INTEGER - now)
|
|
59
|
+
fail('invalid-input');
|
|
60
|
+
return now + duration;
|
|
61
|
+
}
|
|
62
|
+
function validPath(value) {
|
|
63
|
+
const path = text(value);
|
|
64
|
+
if (isAbsolute(path) || path.split('/').some(segment => segment.length === 0 || segment === '.' || segment === '..'))
|
|
65
|
+
fail('invalid-input');
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
function stableJson(value) {
|
|
69
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string')
|
|
70
|
+
return JSON.stringify(value);
|
|
71
|
+
if (typeof value === 'number') {
|
|
72
|
+
if (!Number.isSafeInteger(value))
|
|
73
|
+
fail('invalid-input');
|
|
74
|
+
return JSON.stringify(value);
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(value))
|
|
77
|
+
return `[${array(value, 10_000).map(stableJson).join(',')}]`;
|
|
78
|
+
if (plain(value))
|
|
79
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
80
|
+
return fail('invalid-input');
|
|
81
|
+
}
|
|
82
|
+
function storedJson(value) {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(value);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return fail('schema');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function digest(value) {
|
|
91
|
+
const result = text(value, 64);
|
|
92
|
+
if (!/^[0-9a-f]{64}$/iu.test(result))
|
|
93
|
+
fail('invalid-input');
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
function identityInput(value) {
|
|
97
|
+
const input = object(value, ['principalDigest', 'principalRecordId', 'principalVersion', 'workspace', 'agentPreset']);
|
|
98
|
+
const workspace = text(input.workspace);
|
|
99
|
+
if (!isAbsolute(workspace))
|
|
100
|
+
fail('invalid-input');
|
|
101
|
+
return Object.freeze({ principalDigest: text(input.principalDigest), principalRecordId: text(input.principalRecordId), principalVersion: integer(input.principalVersion, 1), workspace, agentPreset: text(input.agentPreset) });
|
|
102
|
+
}
|
|
103
|
+
function grantInput(value) {
|
|
104
|
+
const input = object(value, ['id', 'revision', 'principalDigest', 'principalRecordId', 'principalVersion', 'workspace', 'agentPreset', 'repository', 'branch', 'paths', 'credentialHandle', 'expiresAt', 'maxActions', 'maxTotalBytes'], ['repoWorkflow', 'verifiedDelivery', 'rollback']);
|
|
105
|
+
const identity = identityInput({ principalDigest: input.principalDigest, principalRecordId: input.principalRecordId, principalVersion: input.principalVersion, workspace: input.workspace, agentPreset: input.agentPreset });
|
|
106
|
+
const paths = array(input.paths, 1_000).map(validPath);
|
|
107
|
+
if (paths.length === 0 || new Set(paths).size !== paths.length)
|
|
108
|
+
fail('invalid-input');
|
|
109
|
+
const workflow = input.repoWorkflow === undefined ? undefined : object(input.repoWorkflow, ['baseBranch', 'allowBranchCreate', 'allowPullRequest']);
|
|
110
|
+
if (workflow && (typeof workflow.allowBranchCreate !== 'boolean' || typeof workflow.allowPullRequest !== 'boolean'))
|
|
111
|
+
fail('invalid-input');
|
|
112
|
+
const delivery = input.verifiedDelivery === undefined ? undefined : object(input.verifiedDelivery, ['ownerRouteId', 'budgetId'], ['acceptance']);
|
|
113
|
+
if (delivery?.acceptance !== undefined && delivery.acceptance !== 'goal-outcome' && delivery.acceptance !== 'goal-step')
|
|
114
|
+
fail('invalid-input');
|
|
115
|
+
const rollback = input.rollback === undefined ? undefined : object(input.rollback, ['allowRollback', 'budgetId', 'maxActions', 'maxTotalBytes']);
|
|
116
|
+
if (rollback && rollback.allowRollback !== true)
|
|
117
|
+
fail('invalid-input');
|
|
118
|
+
return Object.freeze({ ...identity, id: text(input.id), revision: integer(input.revision, 1), repository: text(input.repository), branch: text(input.branch), paths, credentialHandle: text(input.credentialHandle), expiresAt: integer(input.expiresAt, 0), maxActions: integer(input.maxActions, 1, maxRecords), maxTotalBytes: integer(input.maxTotalBytes, 0),
|
|
119
|
+
...(workflow ? { repoWorkflow: Object.freeze({ baseBranch: text(workflow.baseBranch), allowBranchCreate: workflow.allowBranchCreate, allowPullRequest: workflow.allowPullRequest }) } : {}),
|
|
120
|
+
...(delivery ? { verifiedDelivery: Object.freeze({ ownerRouteId: text(delivery.ownerRouteId, 200), budgetId: text(delivery.budgetId, 200), ...(delivery.acceptance === undefined ? {} : { acceptance: delivery.acceptance }) }) } : {}),
|
|
121
|
+
...(rollback ? { rollback: Object.freeze({ allowRollback: true, budgetId: text(rollback.budgetId, 200), maxActions: integer(rollback.maxActions, 1, maxRecords), maxTotalBytes: integer(rollback.maxTotalBytes, 1, 64 * 1024 * 1024) }) } : {}) });
|
|
122
|
+
}
|
|
123
|
+
export function normalizeWorkflow(value) {
|
|
124
|
+
if (plain(value) && own(value, 'operation')) {
|
|
125
|
+
const input = object(value, ['operation', 'grantId', 'idempotencyKey', 'kind'], ['path', 'pullRequestNumber']);
|
|
126
|
+
if (input.operation !== 'inspect' || !['repository', 'branch', 'file', 'pull-request', 'checks', 'reviews'].includes(String(input.kind)))
|
|
127
|
+
fail('invalid-input');
|
|
128
|
+
const needsPr = ['pull-request', 'checks', 'reviews'].includes(String(input.kind));
|
|
129
|
+
if (own(input, 'path') !== (input.kind === 'file') || own(input, 'pullRequestNumber') !== needsPr)
|
|
130
|
+
fail('invalid-input');
|
|
131
|
+
return Object.freeze({ operation: 'inspect', grantId: text(input.grantId), idempotencyKey: text(input.idempotencyKey), kind: input.kind,
|
|
132
|
+
...(input.kind === 'file' ? { path: validPath(input.path) } : {}), ...(needsPr ? { pullRequestNumber: integer(input.pullRequestNumber, 1) } : {}) });
|
|
133
|
+
}
|
|
134
|
+
if (plain(value) && Object.prototype.hasOwnProperty.call(value, 'baseHeadOid')) {
|
|
135
|
+
const input = object(value, ['grantId', 'idempotencyKey', 'baseHeadOid']);
|
|
136
|
+
const baseHeadOid = text(input.baseHeadOid, 40);
|
|
137
|
+
if (!/^[0-9a-f]{40}$/iu.test(baseHeadOid))
|
|
138
|
+
fail('invalid-input');
|
|
139
|
+
return Object.freeze({ grantId: text(input.grantId), idempotencyKey: text(input.idempotencyKey), baseHeadOid });
|
|
140
|
+
}
|
|
141
|
+
if (plain(value) && Object.prototype.hasOwnProperty.call(value, 'title')) {
|
|
142
|
+
const input = object(value, ['grantId', 'idempotencyKey', 'expectedHeadOid', 'title', 'body']);
|
|
143
|
+
const expectedHeadOid = text(input.expectedHeadOid, 40);
|
|
144
|
+
if (!/^[0-9a-f]{40}$/iu.test(expectedHeadOid))
|
|
145
|
+
fail('invalid-input');
|
|
146
|
+
return Object.freeze({ grantId: text(input.grantId), idempotencyKey: text(input.idempotencyKey), expectedHeadOid, title: text(input.title, 200), body: text(input.body, 65_536, 0) });
|
|
147
|
+
}
|
|
148
|
+
const input = object(value, ['grantId', 'idempotencyKey', 'expectedHeadOid', 'headline', 'files']);
|
|
149
|
+
const files = array(input.files, 1_000).map(item => {
|
|
150
|
+
const file = object(item, ['path', 'content']);
|
|
151
|
+
return Object.freeze({ path: validPath(file.path), content: text(file.content, 10_000_000, 0) });
|
|
152
|
+
});
|
|
153
|
+
if (files.length === 0 || new Set(files.map(file => file.path)).size !== files.length)
|
|
154
|
+
fail('invalid-input');
|
|
155
|
+
const expectedHeadOid = text(input.expectedHeadOid, 40);
|
|
156
|
+
if (!/^[0-9a-f]{40}$/iu.test(expectedHeadOid))
|
|
157
|
+
fail('invalid-input');
|
|
158
|
+
return Object.freeze({ grantId: text(input.grantId), idempotencyKey: text(input.idempotencyKey), expectedHeadOid, headline: text(input.headline), files });
|
|
159
|
+
}
|
|
160
|
+
function resultInput(value) {
|
|
161
|
+
const input = object(value, ['actionId', 'status'], ['commitOid', 'reason', 'branch', 'pullRequestNumber']);
|
|
162
|
+
if (typeof input.status !== 'string' || !['succeeded', 'failed', 'unknown'].includes(input.status))
|
|
163
|
+
fail('invalid-input');
|
|
164
|
+
if (own(input, 'commitOid'))
|
|
165
|
+
text(input.commitOid);
|
|
166
|
+
if (own(input, 'reason'))
|
|
167
|
+
text(input.reason, 4_096);
|
|
168
|
+
if (own(input, 'branch'))
|
|
169
|
+
text(input.branch, 256);
|
|
170
|
+
if (own(input, 'pullRequestNumber'))
|
|
171
|
+
integer(input.pullRequestNumber, 1);
|
|
172
|
+
return Object.freeze({ actionId: text(input.actionId), status: input.status, ...(own(input, 'commitOid') ? { commitOid: input.commitOid } : {}), ...(own(input, 'reason') ? { reason: input.reason } : {}), ...(own(input, 'branch') ? { branch: input.branch } : {}), ...(own(input, 'pullRequestNumber') ? { pullRequestNumber: input.pullRequestNumber } : {}) });
|
|
173
|
+
}
|
|
174
|
+
function authorityInput(value) {
|
|
175
|
+
const input = object(value, ['ownerId', 'fence']);
|
|
176
|
+
return Object.freeze({ ownerId: text(input.ownerId), fence: integer(input.fence, 1) });
|
|
177
|
+
}
|
|
178
|
+
function pathList(value) {
|
|
179
|
+
const paths = array(value, 1_000).map(validPath);
|
|
180
|
+
if (paths.length === 0 || new Set(paths).size !== paths.length)
|
|
181
|
+
fail('invalid-input');
|
|
182
|
+
return paths;
|
|
183
|
+
}
|
|
184
|
+
function compensationFiles(value) {
|
|
185
|
+
const files = array(value, 1_000).map(item => {
|
|
186
|
+
if (!plain(item) || item.state !== 'present' && item.state !== 'absent')
|
|
187
|
+
fail('invalid-input');
|
|
188
|
+
if (item.state === 'absent') {
|
|
189
|
+
const file = object(item, ['path', 'state']);
|
|
190
|
+
return Object.freeze({ path: validPath(file.path), state: 'absent' });
|
|
191
|
+
}
|
|
192
|
+
const file = object(item, ['path', 'state', 'blobOid', 'content', 'size']);
|
|
193
|
+
const blobOid = text(file.blobOid, 128);
|
|
194
|
+
if (!/^[0-9a-f]{40,128}$/iu.test(blobOid))
|
|
195
|
+
fail('invalid-input');
|
|
196
|
+
const content = text(file.content, 1_048_576, 0);
|
|
197
|
+
const size = integer(file.size, 0, 1_048_576);
|
|
198
|
+
if (Buffer.byteLength(content) !== size || Buffer.from(content).toString('utf8') !== content)
|
|
199
|
+
fail('invalid-input');
|
|
200
|
+
return Object.freeze({ path: validPath(file.path), state: 'present', blobOid, content, size });
|
|
201
|
+
});
|
|
202
|
+
if (files.length === 0 || new Set(files.map(file => file.path)).size !== files.length
|
|
203
|
+
|| files.reduce((total, file) => total + Buffer.byteLength(file.path) + (file.state === 'present' ? file.size : 0), 0) > 1_048_576)
|
|
204
|
+
fail('invalid-input');
|
|
205
|
+
return files;
|
|
206
|
+
}
|
|
207
|
+
function compensationInput(value) {
|
|
208
|
+
const input = object(value, ['grantId', 'idempotencyKey', 'forwardActionId', 'forwardActionVersion', 'forwardRequestDigest', 'forwardCommitOid']);
|
|
209
|
+
const forwardCommitOid = text(input.forwardCommitOid, 128);
|
|
210
|
+
if (!/^[0-9a-f]{40,128}$/iu.test(forwardCommitOid))
|
|
211
|
+
fail('invalid-input');
|
|
212
|
+
return Object.freeze({ grantId: text(input.grantId), idempotencyKey: text(input.idempotencyKey), forwardActionId: text(input.forwardActionId), forwardActionVersion: integer(input.forwardActionVersion, 1),
|
|
213
|
+
forwardRequestDigest: digest(input.forwardRequestDigest), forwardCommitOid });
|
|
214
|
+
}
|
|
215
|
+
function compensationPreimage(value) {
|
|
216
|
+
const input = object(value, ['repository', 'branch', 'commitOid', 'files']);
|
|
217
|
+
const commitOid = text(input.commitOid, 128);
|
|
218
|
+
if (!/^[0-9a-f]{40,128}$/iu.test(commitOid))
|
|
219
|
+
fail('invalid-input');
|
|
220
|
+
return Object.freeze({ repository: text(input.repository), branch: text(input.branch, 256), commitOid, files: Object.freeze(compensationFiles(input.files)) });
|
|
221
|
+
}
|
|
222
|
+
function compensationBytes(preimage) { return Buffer.byteLength(stableJson(preimage), 'utf8'); }
|
|
223
|
+
function compensationResultInput(value) {
|
|
224
|
+
const input = object(value, ['actionId', 'status', 'repository', 'branch', 'parentOid', 'actionMarker'], ['resultOid', 'reason']);
|
|
225
|
+
if (!['succeeded', 'failed', 'unknown'].includes(String(input.status)))
|
|
226
|
+
fail('invalid-input');
|
|
227
|
+
const resultOid = input.resultOid === undefined ? undefined : text(input.resultOid, 128);
|
|
228
|
+
if (resultOid !== undefined && !/^[0-9a-f]{40,128}$/iu.test(resultOid))
|
|
229
|
+
fail('invalid-input');
|
|
230
|
+
if ((input.status === 'succeeded') !== (resultOid !== undefined))
|
|
231
|
+
fail('invalid-input');
|
|
232
|
+
return Object.freeze({ actionId: text(input.actionId), status: input.status, repository: text(input.repository), branch: text(input.branch, 256), parentOid: text(input.parentOid, 128), actionMarker: text(input.actionMarker),
|
|
233
|
+
...(resultOid === undefined ? {} : { resultOid }), ...(input.reason === undefined ? {} : { reason: text(input.reason, 4_096) }) });
|
|
234
|
+
}
|
|
235
|
+
function privateFile(path) {
|
|
236
|
+
const stat = lstatSync(path);
|
|
237
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || (process.getuid?.() !== undefined && stat.uid !== process.getuid?.()))
|
|
238
|
+
fail('unsafe-file');
|
|
239
|
+
}
|
|
240
|
+
function privateDirectory(path) {
|
|
241
|
+
const stat = lstatSync(path);
|
|
242
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (process.getuid?.() !== undefined && stat.uid !== process.getuid?.()))
|
|
243
|
+
fail('unsafe-file');
|
|
244
|
+
}
|
|
245
|
+
function preparePath(path) {
|
|
246
|
+
if (!isAbsolute(path))
|
|
247
|
+
fail('unsafe-file');
|
|
248
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
249
|
+
chmodSync(dirname(path), 0o700);
|
|
250
|
+
privateDirectory(dirname(path));
|
|
251
|
+
let existing = false;
|
|
252
|
+
try {
|
|
253
|
+
lstatSync(path);
|
|
254
|
+
existing = true;
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT')
|
|
258
|
+
fail('unsafe-file');
|
|
259
|
+
}
|
|
260
|
+
if (!existing) {
|
|
261
|
+
const descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
262
|
+
closeSync(descriptor);
|
|
263
|
+
}
|
|
264
|
+
privateFile(path);
|
|
265
|
+
chmodSync(path, 0o600);
|
|
266
|
+
privateFile(path);
|
|
267
|
+
}
|
|
268
|
+
function open(path) {
|
|
269
|
+
if (path !== ':memory:')
|
|
270
|
+
preparePath(path);
|
|
271
|
+
const database = new DatabaseSync(path, { enableForeignKeyConstraints: true });
|
|
272
|
+
try {
|
|
273
|
+
database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;');
|
|
274
|
+
const version = database.prepare('PRAGMA user_version').get().user_version;
|
|
275
|
+
if (version === 0)
|
|
276
|
+
database.exec(`
|
|
277
|
+
BEGIN IMMEDIATE;
|
|
278
|
+
CREATE TABLE grants (id TEXT NOT NULL, revision INTEGER NOT NULL, grant_json TEXT NOT NULL, revoked INTEGER NOT NULL CHECK(revoked IN (0, 1)), PRIMARY KEY(id, revision)) STRICT, WITHOUT ROWID;
|
|
279
|
+
CREATE TABLE grant_heads (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, revoked INTEGER NOT NULL CHECK(revoked IN (0, 1)), FOREIGN KEY(id, revision) REFERENCES grants(id, revision)) STRICT;
|
|
280
|
+
CREATE TABLE actions (kind TEXT NOT NULL DEFAULT 'commit' CHECK(kind IN ('commit', 'branch', 'pull-request', 'inspect')), id TEXT PRIMARY KEY, identity_json TEXT NOT NULL, session_id TEXT NOT NULL, grant_id TEXT NOT NULL, idempotency_digest TEXT NOT NULL, grant_revision INTEGER NOT NULL, repository TEXT NOT NULL, branch TEXT NOT NULL, expected_head_oid TEXT NOT NULL, request_digest TEXT NOT NULL, bytes INTEGER NOT NULL, expires_at INTEGER NOT NULL, status TEXT NOT NULL CHECK(status IN ('prepared', 'dispatched', 'succeeded', 'failed', 'unknown')), version INTEGER NOT NULL, result_json TEXT, paths_json TEXT, UNIQUE(identity_json, session_id, grant_id, idempotency_digest), FOREIGN KEY(grant_id, grant_revision) REFERENCES grants(id, revision)) STRICT;
|
|
281
|
+
CREATE TABLE compensations (id TEXT PRIMARY KEY, forward_action_id TEXT NOT NULL UNIQUE, forward_action_version INTEGER NOT NULL, forward_grant_revision INTEGER NOT NULL, identity_json TEXT NOT NULL, session_id TEXT NOT NULL, grant_id TEXT NOT NULL, grant_revision INTEGER NOT NULL, repository TEXT NOT NULL, branch TEXT NOT NULL, paths_json TEXT NOT NULL, parent_oid TEXT NOT NULL, idempotency_digest TEXT NOT NULL, forward_request_digest TEXT NOT NULL, forward_commit_oid TEXT NOT NULL, request_digest TEXT NOT NULL, preimage_digest TEXT, preimage_json TEXT, budget_id TEXT NOT NULL, budget_max_actions INTEGER NOT NULL, budget_max_total_bytes INTEGER NOT NULL, bytes INTEGER NOT NULL, expires_at INTEGER NOT NULL, status TEXT NOT NULL CHECK(status IN ('capturing', 'prepared', 'dispatched', 'succeeded', 'failed', 'unknown')), version INTEGER NOT NULL, result_json TEXT, CHECK((status = 'capturing' AND preimage_digest IS NULL AND preimage_json IS NULL AND bytes = 0) OR (status != 'capturing' AND preimage_digest IS NOT NULL AND preimage_json IS NOT NULL)), UNIQUE(identity_json, session_id, grant_id, idempotency_digest), FOREIGN KEY(forward_action_id) REFERENCES actions(id), FOREIGN KEY(grant_id, grant_revision) REFERENCES grants(id, revision)) STRICT;
|
|
282
|
+
CREATE TABLE controller (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), owner_id TEXT NOT NULL, fence INTEGER NOT NULL, expires_at INTEGER NOT NULL) STRICT;
|
|
283
|
+
CREATE TABLE audit (sequence INTEGER PRIMARY KEY, kind TEXT NOT NULL, subject_id TEXT NOT NULL, revision INTEGER, recorded_at INTEGER NOT NULL) STRICT;
|
|
284
|
+
CREATE INDEX actions_grant ON actions(grant_id, grant_revision);
|
|
285
|
+
CREATE INDEX actions_destination_head ON actions(repository, branch, expected_head_oid, status);
|
|
286
|
+
CREATE INDEX compensations_status ON compensations(status);
|
|
287
|
+
PRAGMA user_version = 3;
|
|
288
|
+
COMMIT;`);
|
|
289
|
+
const compensationSchema = "CREATE TABLE compensations (id TEXT PRIMARY KEY, forward_action_id TEXT NOT NULL UNIQUE, forward_action_version INTEGER NOT NULL, forward_grant_revision INTEGER NOT NULL, identity_json TEXT NOT NULL, session_id TEXT NOT NULL, grant_id TEXT NOT NULL, grant_revision INTEGER NOT NULL, repository TEXT NOT NULL, branch TEXT NOT NULL, paths_json TEXT NOT NULL, parent_oid TEXT NOT NULL, idempotency_digest TEXT NOT NULL, forward_request_digest TEXT NOT NULL, forward_commit_oid TEXT NOT NULL, request_digest TEXT NOT NULL, preimage_digest TEXT, preimage_json TEXT, budget_id TEXT NOT NULL, budget_max_actions INTEGER NOT NULL, budget_max_total_bytes INTEGER NOT NULL, bytes INTEGER NOT NULL, expires_at INTEGER NOT NULL, status TEXT NOT NULL CHECK(status IN ('capturing', 'prepared', 'dispatched', 'succeeded', 'failed', 'unknown')), version INTEGER NOT NULL, result_json TEXT, CHECK((status = 'capturing' AND preimage_digest IS NULL AND preimage_json IS NULL AND bytes = 0) OR (status != 'capturing' AND preimage_digest IS NOT NULL AND preimage_json IS NOT NULL)), UNIQUE(identity_json, session_id, grant_id, idempotency_digest), FOREIGN KEY(forward_action_id) REFERENCES actions(id), FOREIGN KEY(grant_id, grant_revision) REFERENCES grants(id, revision)) STRICT; CREATE INDEX compensations_status ON compensations(status);";
|
|
290
|
+
if (version === 1)
|
|
291
|
+
database.exec(`BEGIN IMMEDIATE; ALTER TABLE actions ADD COLUMN kind TEXT NOT NULL DEFAULT 'commit' CHECK(kind IN ('commit', 'branch', 'pull-request', 'inspect')); ALTER TABLE actions ADD COLUMN paths_json TEXT; ${compensationSchema} PRAGMA user_version = 3; COMMIT;`);
|
|
292
|
+
if (version === 2)
|
|
293
|
+
database.exec(`BEGIN IMMEDIATE; ALTER TABLE actions ADD COLUMN paths_json TEXT; ${compensationSchema} PRAGMA user_version = 3; COMMIT;`);
|
|
294
|
+
if (![0, 1, 2, schemaVersion].includes(version))
|
|
295
|
+
fail('schema');
|
|
296
|
+
const tables = database.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map(row => row.name);
|
|
297
|
+
if (!equal(tables, ['actions', 'audit', 'compensations', 'controller', 'grant_heads', 'grants']))
|
|
298
|
+
fail('schema');
|
|
299
|
+
const strict = database.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
300
|
+
if (!strict.every(row => /\bSTRICT\b/u.test(row.sql)))
|
|
301
|
+
fail('schema');
|
|
302
|
+
const check = database.prepare('PRAGMA quick_check').get();
|
|
303
|
+
if (check.quick_check !== 'ok')
|
|
304
|
+
fail('schema');
|
|
305
|
+
const actionCount = database.prepare('SELECT COUNT(*) AS count FROM actions').get().count;
|
|
306
|
+
if (!Number.isSafeInteger(actionCount) || actionCount > maxRecords)
|
|
307
|
+
fail('schema');
|
|
308
|
+
if (path !== ':memory:') {
|
|
309
|
+
privateFile(path);
|
|
310
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
311
|
+
try {
|
|
312
|
+
chmodSync(`${path}${suffix}`, 0o600);
|
|
313
|
+
privateFile(`${path}${suffix}`);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT')
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return database;
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
database.close();
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
export class ActionLedger {
|
|
329
|
+
#database;
|
|
330
|
+
#now;
|
|
331
|
+
constructor(path, options = {}) {
|
|
332
|
+
if (!plain(options) || Object.keys(options).some(key => key !== 'now') || (options.now !== undefined && typeof options.now !== 'function'))
|
|
333
|
+
fail('invalid-input');
|
|
334
|
+
this.#now = options.now ?? Date.now;
|
|
335
|
+
this.#database = open(path);
|
|
336
|
+
try {
|
|
337
|
+
this.#validateStored();
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
this.#database.close();
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
#time() { return integer(this.#now(), 0); }
|
|
345
|
+
#audit(kind, subjectId, revision, now) { this.#database.prepare('INSERT INTO audit(kind, subject_id, revision, recorded_at) VALUES (?, ?, ?, ?)').run(kind, subjectId, revision, now); }
|
|
346
|
+
#controller(authority, now = this.#time()) {
|
|
347
|
+
const input = authorityInput(authority);
|
|
348
|
+
const row = this.#database.prepare('SELECT owner_id, fence, expires_at FROM controller WHERE singleton = 1').get();
|
|
349
|
+
if (!row)
|
|
350
|
+
fail('controller');
|
|
351
|
+
let owner;
|
|
352
|
+
let fence;
|
|
353
|
+
let expiresAt;
|
|
354
|
+
try {
|
|
355
|
+
owner = text(row.owner_id);
|
|
356
|
+
fence = integer(row.fence, 1);
|
|
357
|
+
expiresAt = integer(row.expires_at, 0);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
fail('schema');
|
|
361
|
+
}
|
|
362
|
+
if (owner !== input.ownerId || fence !== input.fence || expiresAt <= now)
|
|
363
|
+
fail('controller');
|
|
364
|
+
return input;
|
|
365
|
+
}
|
|
366
|
+
#grantRow(row) {
|
|
367
|
+
try {
|
|
368
|
+
const grant = grantInput(storedJson(row.grant_json));
|
|
369
|
+
if (grant.id !== text(row.id) || grant.revision !== integer(row.revision, 1) || (row.revoked !== 0 && row.revoked !== 1))
|
|
370
|
+
fail('schema');
|
|
371
|
+
return grant;
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return fail('schema');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
#grantRevision(id, revision) {
|
|
378
|
+
const row = this.#database.prepare('SELECT id, revision, grant_json, revoked FROM grants WHERE id = ? AND revision = ?').get(id, revision);
|
|
379
|
+
if (!row)
|
|
380
|
+
fail('schema');
|
|
381
|
+
return this.#grantRow(row);
|
|
382
|
+
}
|
|
383
|
+
#row(row) {
|
|
384
|
+
try {
|
|
385
|
+
const id = text(row.id);
|
|
386
|
+
const identity = identityInput(storedJson(row.identity_json));
|
|
387
|
+
const sessionId = text(row.session_id);
|
|
388
|
+
const grantId = text(row.grant_id);
|
|
389
|
+
const grantRevision = integer(row.grant_revision, 1);
|
|
390
|
+
digest(row.idempotency_digest);
|
|
391
|
+
const requestDigest = digest(row.request_digest);
|
|
392
|
+
const bytes = integer(row.bytes, 0);
|
|
393
|
+
const expiresAt = integer(row.expires_at, 0);
|
|
394
|
+
const version = integer(row.version, 1);
|
|
395
|
+
if (!['commit', 'branch', 'pull-request', 'inspect'].includes(row.kind))
|
|
396
|
+
fail('schema');
|
|
397
|
+
if (!['prepared', 'dispatched', 'succeeded', 'failed', 'unknown'].includes(row.status))
|
|
398
|
+
fail('schema');
|
|
399
|
+
const status = row.status;
|
|
400
|
+
const terminal = ['succeeded', 'failed', 'unknown'].includes(status);
|
|
401
|
+
if (terminal !== (row.result_json !== null))
|
|
402
|
+
fail('schema');
|
|
403
|
+
const result = row.result_json === null ? undefined : resultInput(storedJson(row.result_json));
|
|
404
|
+
if (result && (result.actionId !== id || result.status !== status))
|
|
405
|
+
fail('schema');
|
|
406
|
+
const paths = row.paths_json === null ? undefined : pathList(storedJson(row.paths_json));
|
|
407
|
+
if (paths !== undefined && row.kind !== 'commit')
|
|
408
|
+
fail('schema');
|
|
409
|
+
const grant = this.#grantRevision(grantId, grantRevision);
|
|
410
|
+
const grantIdentity = { principalDigest: grant.principalDigest, principalRecordId: grant.principalRecordId, principalVersion: grant.principalVersion, workspace: grant.workspace, agentPreset: grant.agentPreset };
|
|
411
|
+
if (!equal(identity, grantIdentity) || grant.repository !== text(row.repository) || grant.branch !== text(row.branch) || (row.kind === 'inspect' ? row.expected_head_oid !== '' : !/^[0-9a-f]{40}$/iu.test(row.expected_head_oid)))
|
|
412
|
+
fail('schema');
|
|
413
|
+
return Object.freeze({ kind: row.kind, id, identity, sessionId, grantId, grantRevision, requestDigest, bytes, expiresAt, status, version, ...(paths ? { paths: Object.freeze(paths) } : {}), ...(result ? { result } : {}) });
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return fail('schema');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
#compensationRow(row) {
|
|
420
|
+
try {
|
|
421
|
+
const id = text(row.id);
|
|
422
|
+
const forwardActionId = text(row.forward_action_id);
|
|
423
|
+
const forwardActionVersion = integer(row.forward_action_version, 1);
|
|
424
|
+
const forwardGrantRevision = integer(row.forward_grant_revision, 1);
|
|
425
|
+
const identity = identityInput(storedJson(row.identity_json));
|
|
426
|
+
const sessionId = text(row.session_id);
|
|
427
|
+
const grantId = text(row.grant_id);
|
|
428
|
+
const grantRevision = integer(row.grant_revision, 1);
|
|
429
|
+
const repository = text(row.repository);
|
|
430
|
+
const branch = text(row.branch, 256);
|
|
431
|
+
const paths = pathList(storedJson(row.paths_json));
|
|
432
|
+
const parentOid = text(row.parent_oid, 128);
|
|
433
|
+
if (!/^[0-9a-f]{40,128}$/iu.test(parentOid))
|
|
434
|
+
fail('schema');
|
|
435
|
+
digest(row.idempotency_digest);
|
|
436
|
+
const forwardRequestDigest = digest(row.forward_request_digest);
|
|
437
|
+
const forwardCommitOid = text(row.forward_commit_oid, 128);
|
|
438
|
+
if (!/^[0-9a-f]{40,128}$/iu.test(forwardCommitOid))
|
|
439
|
+
fail('schema');
|
|
440
|
+
const requestDigest = digest(row.request_digest);
|
|
441
|
+
const budgetId = text(row.budget_id, 200);
|
|
442
|
+
const budgetMaxActions = integer(row.budget_max_actions, 1, maxRecords);
|
|
443
|
+
const budgetMaxTotalBytes = integer(row.budget_max_total_bytes, 1, 64 * 1024 * 1024);
|
|
444
|
+
const bytes = integer(row.bytes, 0, 64 * 1024 * 1024);
|
|
445
|
+
const expiresAt = integer(row.expires_at, 0);
|
|
446
|
+
const version = integer(row.version, 1);
|
|
447
|
+
if (!['capturing', 'prepared', 'dispatched', 'succeeded', 'failed', 'unknown'].includes(row.status))
|
|
448
|
+
fail('schema');
|
|
449
|
+
const status = row.status;
|
|
450
|
+
const terminal = ['succeeded', 'failed', 'unknown'].includes(status);
|
|
451
|
+
if (terminal !== (row.result_json !== null) || (status === 'capturing') !== (row.preimage_json === null || row.preimage_digest === null))
|
|
452
|
+
fail('schema');
|
|
453
|
+
if ((row.preimage_json === null) !== (row.preimage_digest === null) || (status === 'capturing' && bytes !== 0))
|
|
454
|
+
fail('schema');
|
|
455
|
+
const preimage = row.preimage_json === null ? undefined : compensationPreimage(storedJson(row.preimage_json));
|
|
456
|
+
const preimageDigest = row.preimage_digest === null ? undefined : digest(row.preimage_digest);
|
|
457
|
+
if (preimage && (preimageDigest !== createHash('sha256').update(stableJson(preimage)).digest('hex') || preimage.repository !== repository || preimage.branch !== branch || preimage.commitOid !== parentOid
|
|
458
|
+
|| !equal(preimage.files.map(file => file.path), paths) || bytes !== compensationBytes(preimage)))
|
|
459
|
+
fail('schema');
|
|
460
|
+
const result = row.result_json === null ? undefined : compensationResultInput(storedJson(row.result_json));
|
|
461
|
+
if (result && (result.actionId !== id || result.status !== status || result.repository !== repository || result.branch !== branch || result.parentOid !== forwardCommitOid || result.actionMarker !== `dsh-compensation:${id}`))
|
|
462
|
+
fail('schema');
|
|
463
|
+
const grant = this.#grantRevision(grantId, grantRevision);
|
|
464
|
+
const rollback = grant.rollback;
|
|
465
|
+
const grantIdentity = { principalDigest: grant.principalDigest, principalRecordId: grant.principalRecordId, principalVersion: grant.principalVersion, workspace: grant.workspace, agentPreset: grant.agentPreset };
|
|
466
|
+
if (!rollback || !equal(identity, grantIdentity) || rollback.budgetId !== budgetId || rollback.maxActions !== budgetMaxActions || rollback.maxTotalBytes !== budgetMaxTotalBytes
|
|
467
|
+
|| grant.repository !== repository || grant.branch !== branch || !paths.every(path => grant.paths.includes(path)))
|
|
468
|
+
fail('schema');
|
|
469
|
+
const forwardRow = this.#database.prepare('SELECT * FROM actions WHERE id = ?').get(forwardActionId);
|
|
470
|
+
if (!forwardRow)
|
|
471
|
+
fail('schema');
|
|
472
|
+
const forward = this.#row(forwardRow);
|
|
473
|
+
if (forward.kind !== 'commit' || forward.status !== 'succeeded' || forward.version !== forwardActionVersion || forward.grantRevision !== forwardGrantRevision
|
|
474
|
+
|| forward.grantId !== grantId || forward.sessionId !== sessionId || !equal(forward.identity, identity) || !equal(forward.paths, paths) || forward.requestDigest !== forwardRequestDigest
|
|
475
|
+
|| forwardRow.repository !== repository || forwardRow.branch !== branch || forwardRow.expected_head_oid !== parentOid || forward.result?.commitOid !== forwardCommitOid)
|
|
476
|
+
fail('schema');
|
|
477
|
+
return Object.freeze({ id, forwardActionId, forwardActionVersion, forwardGrantRevision, identity, sessionId, grantId, grantRevision, repository, branch, paths: Object.freeze(paths), parentOid,
|
|
478
|
+
forwardRequestDigest, forwardCommitOid, requestDigest, bytes, expiresAt, status, version, ...(preimageDigest ? { preimageDigest } : {}), ...(preimage ? { preimage } : {}), ...(result ? { result } : {}) });
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return fail('schema');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
#currentGrant(id) {
|
|
485
|
+
const row = this.#database.prepare('SELECT g.id, g.revision, g.grant_json, h.revoked, g.revoked AS grant_revoked FROM grants g JOIN grant_heads h ON h.id = g.id AND h.revision = g.revision WHERE g.id = ?').get(id);
|
|
486
|
+
if (row && row.revoked !== row.grant_revoked)
|
|
487
|
+
fail('schema');
|
|
488
|
+
return row;
|
|
489
|
+
}
|
|
490
|
+
#validateStored() {
|
|
491
|
+
try {
|
|
492
|
+
const grants = this.#database.prepare('SELECT id, revision, grant_json, revoked FROM grants').all();
|
|
493
|
+
for (const grant of grants)
|
|
494
|
+
this.#grantRow(grant);
|
|
495
|
+
const heads = this.#database.prepare('SELECT h.id, h.revision, h.revoked, g.grant_json FROM grant_heads h JOIN grants g ON g.id = h.id AND g.revision = h.revision').all();
|
|
496
|
+
if (heads.length !== this.#database.prepare('SELECT COUNT(*) AS count FROM grant_heads').get().count)
|
|
497
|
+
fail('schema');
|
|
498
|
+
for (const head of heads) {
|
|
499
|
+
this.#grantRow(head);
|
|
500
|
+
this.#currentGrant(head.id);
|
|
501
|
+
}
|
|
502
|
+
const controllers = this.#database.prepare('SELECT owner_id, fence, expires_at FROM controller').all();
|
|
503
|
+
if (controllers.length > 1)
|
|
504
|
+
fail('schema');
|
|
505
|
+
for (const controller of controllers) {
|
|
506
|
+
text(controller.owner_id);
|
|
507
|
+
integer(controller.fence, 1);
|
|
508
|
+
integer(controller.expires_at, 0);
|
|
509
|
+
}
|
|
510
|
+
const actions = this.#database.prepare('SELECT * FROM actions LIMIT 10001').all();
|
|
511
|
+
if (actions.length > maxRecords)
|
|
512
|
+
fail('schema');
|
|
513
|
+
for (const action of actions)
|
|
514
|
+
this.#row(action);
|
|
515
|
+
const compensations = this.#database.prepare('SELECT * FROM compensations LIMIT 10001').all();
|
|
516
|
+
if (compensations.length > maxRecords)
|
|
517
|
+
fail('schema');
|
|
518
|
+
for (const compensation of compensations)
|
|
519
|
+
this.#compensationRow(compensation);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
fail('schema');
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
claimController(ownerId, ttlMs = 30_000) {
|
|
526
|
+
const owner = text(ownerId);
|
|
527
|
+
const ttl = integer(ttlMs, 1);
|
|
528
|
+
const now = this.#time();
|
|
529
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
530
|
+
try {
|
|
531
|
+
const current = this.#database.prepare('SELECT owner_id, fence, expires_at FROM controller WHERE singleton = 1').get();
|
|
532
|
+
if (current && current.expires_at > now)
|
|
533
|
+
fail('controller');
|
|
534
|
+
const fence = after(current ? integer(current.fence, 1) : 0, 1);
|
|
535
|
+
this.#database.prepare('INSERT INTO controller(singleton, owner_id, fence, expires_at) VALUES(1, ?, ?, ?) ON CONFLICT(singleton) DO UPDATE SET owner_id = excluded.owner_id, fence = excluded.fence, expires_at = excluded.expires_at').run(owner, fence, after(now, ttl));
|
|
536
|
+
this.#database.exec('COMMIT');
|
|
537
|
+
return Object.freeze({ ownerId: owner, fence });
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
try {
|
|
541
|
+
this.#database.exec('ROLLBACK');
|
|
542
|
+
}
|
|
543
|
+
catch { }
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
renewController(authority, ttlMs = 30_000) {
|
|
548
|
+
const ttl = integer(ttlMs, 1);
|
|
549
|
+
const now = this.#time();
|
|
550
|
+
const input = this.#controller(authority, now);
|
|
551
|
+
const updated = this.#database.prepare('UPDATE controller SET expires_at = ? WHERE singleton = 1 AND owner_id = ? AND fence = ? AND expires_at > ?').run(after(now, ttl), input.ownerId, input.fence, now);
|
|
552
|
+
if (updated.changes !== 1)
|
|
553
|
+
fail('controller');
|
|
554
|
+
return input;
|
|
555
|
+
}
|
|
556
|
+
hasController(authority) { try {
|
|
557
|
+
this.#controller(authority);
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
if (error instanceof ActionLedgerError && error.code === 'controller')
|
|
562
|
+
return false;
|
|
563
|
+
throw error;
|
|
564
|
+
} }
|
|
565
|
+
releaseController(authority) { const now = this.#time(); const input = this.#controller(authority, now); const result = this.#database.prepare('UPDATE controller SET expires_at = ? WHERE singleton = 1 AND owner_id = ? AND fence = ? AND expires_at > ?').run(now, input.ownerId, input.fence, now); if (result.changes !== 1)
|
|
566
|
+
fail('controller'); }
|
|
567
|
+
syncGrants(values, authority) {
|
|
568
|
+
const grants = array(values, 1_000).map(grantInput);
|
|
569
|
+
if (new Set(grants.map(grant => grant.id)).size !== grants.length)
|
|
570
|
+
fail('invalid-input');
|
|
571
|
+
const now = this.#time();
|
|
572
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
573
|
+
try {
|
|
574
|
+
this.#controller(authority, now);
|
|
575
|
+
const old = this.#database.prepare('SELECT id, revision, revoked FROM grant_heads').all();
|
|
576
|
+
for (const grant of grants) {
|
|
577
|
+
const head = old.find(row => row.id === grant.id);
|
|
578
|
+
if (head && grant.revision < head.revision)
|
|
579
|
+
fail('conflict');
|
|
580
|
+
if (head && grant.revision === head.revision) {
|
|
581
|
+
const stored = this.#database.prepare('SELECT id, revision, grant_json, revoked FROM grants WHERE id = ? AND revision = ?').get(grant.id, grant.revision);
|
|
582
|
+
if (!stored || head.revoked || !equal(this.#grantRow(stored), grant))
|
|
583
|
+
fail('conflict');
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
this.#database.prepare('INSERT INTO grants(id, revision, grant_json, revoked) VALUES (?, ?, ?, 0)').run(grant.id, grant.revision, stableJson(grant));
|
|
587
|
+
this.#database.prepare('INSERT INTO grant_heads(id, revision, revoked) VALUES (?, ?, 0) ON CONFLICT(id) DO UPDATE SET revision = excluded.revision, revoked = 0').run(grant.id, grant.revision);
|
|
588
|
+
this.#audit('grant-synced', grant.id, grant.revision, now);
|
|
589
|
+
}
|
|
590
|
+
const incoming = new Set(grants.map(grant => grant.id));
|
|
591
|
+
for (const head of old)
|
|
592
|
+
if (!incoming.has(head.id) && head.revoked === 0) {
|
|
593
|
+
this.#database.prepare('UPDATE grant_heads SET revoked = 1 WHERE id = ?').run(head.id);
|
|
594
|
+
this.#database.prepare('UPDATE grants SET revoked = 1 WHERE id = ? AND revision = ?').run(head.id, head.revision);
|
|
595
|
+
this.#audit('grant-removed', head.id, head.revision, now);
|
|
596
|
+
}
|
|
597
|
+
this.#database.exec('COMMIT');
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
try {
|
|
601
|
+
this.#database.exec('ROLLBACK');
|
|
602
|
+
}
|
|
603
|
+
catch { }
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
revoke(grantId, revision) {
|
|
608
|
+
const id = text(grantId);
|
|
609
|
+
const rev = integer(revision, 1);
|
|
610
|
+
const now = this.#time();
|
|
611
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
612
|
+
try {
|
|
613
|
+
const result = this.#database.prepare('UPDATE grant_heads SET revoked = 1 WHERE id = ? AND revision = ? AND revoked = 0').run(id, rev);
|
|
614
|
+
if (result.changes === 1) {
|
|
615
|
+
this.#database.prepare('UPDATE grants SET revoked = 1 WHERE id = ? AND revision = ?').run(id, rev);
|
|
616
|
+
this.#audit('grant-revoked', id, rev, now);
|
|
617
|
+
}
|
|
618
|
+
this.#database.exec('COMMIT');
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
try {
|
|
622
|
+
this.#database.exec('ROLLBACK');
|
|
623
|
+
}
|
|
624
|
+
catch { }
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
grant(id) { const row = this.#currentGrant(text(id)); return !row || row.revoked ? undefined : this.#grantRow(row); }
|
|
629
|
+
prepare(input) {
|
|
630
|
+
const args = object(input, ['identity', 'sessionId', 'request', 'bytes', 'authority'], ['leaseMs']);
|
|
631
|
+
const identity = identityInput(args.identity);
|
|
632
|
+
const sessionId = text(args.sessionId);
|
|
633
|
+
const request = normalizeWorkflow(args.request);
|
|
634
|
+
const bytes = integer(args.bytes, 0);
|
|
635
|
+
const lease = own(args, 'leaseMs') ? integer(args.leaseMs, 1, 30_000) : 30_000;
|
|
636
|
+
const calculated = 'files' in request ? Buffer.byteLength(request.headline, 'utf8') + request.files.reduce((total, file) => total + Buffer.byteLength(file.path, 'utf8') + Buffer.byteLength(file.content, 'utf8'), 0) : Buffer.byteLength(stableJson(request), 'utf8');
|
|
637
|
+
if (calculated !== bytes)
|
|
638
|
+
fail('invalid-input');
|
|
639
|
+
const digest = createHash('sha256').update(stableJson(request)).digest('hex');
|
|
640
|
+
const idempotencyDigest = createHash('sha256').update(request.idempotencyKey).digest('hex');
|
|
641
|
+
const now = this.#time();
|
|
642
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
643
|
+
try {
|
|
644
|
+
this.#controller(args.authority, now);
|
|
645
|
+
const keyed = this.#database.prepare('SELECT * FROM actions WHERE identity_json = ? AND session_id = ? AND grant_id = ? AND idempotency_digest = ?').get(stableJson(identity), sessionId, request.grantId, idempotencyDigest);
|
|
646
|
+
if (keyed) {
|
|
647
|
+
if (keyed.request_digest !== digest)
|
|
648
|
+
fail('conflict');
|
|
649
|
+
this.#database.exec('COMMIT');
|
|
650
|
+
return { record: this.#row(keyed), created: false };
|
|
651
|
+
}
|
|
652
|
+
const grantRow = this.#currentGrant(request.grantId);
|
|
653
|
+
if (!grantRow || grantRow.revoked)
|
|
654
|
+
fail('grant');
|
|
655
|
+
const grant = this.#grantRow(grantRow);
|
|
656
|
+
if (!equal(identity, { principalDigest: grant.principalDigest, principalRecordId: grant.principalRecordId, principalVersion: grant.principalVersion, workspace: grant.workspace, agentPreset: grant.agentPreset }) || grant.expiresAt <= now || ('files' in request && !request.files.every(file => grant.paths.includes(file.path))))
|
|
657
|
+
fail('grant');
|
|
658
|
+
const kind = 'operation' in request ? 'inspect' : 'files' in request ? 'commit' : 'baseHeadOid' in request ? 'branch' : 'pull-request';
|
|
659
|
+
if ((kind === 'branch' && !grant.repoWorkflow?.allowBranchCreate) || (kind === 'pull-request' && !grant.repoWorkflow?.allowPullRequest)
|
|
660
|
+
|| ('operation' in request && ((request.kind === 'file' && !grant.paths.includes(request.path)) || (['pull-request', 'checks', 'reviews'].includes(request.kind) && !grant.repoWorkflow))))
|
|
661
|
+
fail('grant');
|
|
662
|
+
const head = 'operation' in request ? '' : 'baseHeadOid' in request ? request.baseHeadOid : request.expectedHeadOid;
|
|
663
|
+
// An uncertain branch/PR creation must not be repeated with a new key or
|
|
664
|
+
// head OID. Reads remain available to investigate uncertain mutations.
|
|
665
|
+
if (kind !== 'inspect') {
|
|
666
|
+
const uncertain = this.#database.prepare("SELECT 1 FROM actions WHERE repository = ? AND branch = ? AND kind != 'inspect' AND status IN ('dispatched', 'unknown') AND (expected_head_oid = ? OR kind = 'branch' OR (kind = 'pull-request' AND ? = 'pull-request')) LIMIT 1").get(grant.repository, grant.branch, head, kind);
|
|
667
|
+
if (uncertain)
|
|
668
|
+
fail('state');
|
|
669
|
+
}
|
|
670
|
+
const active = this.#database.prepare("SELECT COUNT(*) AS count FROM actions WHERE status IN ('prepared', 'dispatched') OR (status = 'unknown' AND kind != 'inspect' AND ? != 'inspect')").get(kind).count;
|
|
671
|
+
if (active >= maxActiveActions)
|
|
672
|
+
fail('limit');
|
|
673
|
+
const used = this.#database.prepare('SELECT COUNT(*) AS count, COALESCE(SUM(bytes), 0) AS bytes FROM actions WHERE grant_id = ?').get(grant.id);
|
|
674
|
+
const records = this.#database.prepare('SELECT COUNT(*) AS count FROM actions').get().count;
|
|
675
|
+
if (records >= maxRecords || used.count >= grant.maxActions || used.bytes + bytes > grant.maxTotalBytes)
|
|
676
|
+
fail('limit');
|
|
677
|
+
const expiresAt = Math.min(after(now, lease), grant.expiresAt);
|
|
678
|
+
const id = randomUUID();
|
|
679
|
+
this.#database.prepare('INSERT INTO actions(kind, id, identity_json, session_id, grant_id, idempotency_digest, grant_revision, repository, branch, expected_head_oid, request_digest, bytes, expires_at, status, version, result_json, paths_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \'prepared\', 1, NULL, ?)').run(kind, id, stableJson(identity), sessionId, grant.id, idempotencyDigest, grant.revision, grant.repository, grant.branch, head, digest, bytes, expiresAt, kind === 'commit' ? stableJson(request.files.map(file => file.path)) : null);
|
|
680
|
+
this.#audit('action-prepared', id, 1, now);
|
|
681
|
+
const row = this.#database.prepare('SELECT * FROM actions WHERE id = ?').get(id);
|
|
682
|
+
this.#database.exec('COMMIT');
|
|
683
|
+
return { record: this.#row(row), created: true };
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
try {
|
|
687
|
+
this.#database.exec('ROLLBACK');
|
|
688
|
+
}
|
|
689
|
+
catch { }
|
|
690
|
+
throw error;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
dispatch(id, version, authority) {
|
|
694
|
+
const actionId = text(id);
|
|
695
|
+
const expected = integer(version, 1);
|
|
696
|
+
const now = this.#time();
|
|
697
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
698
|
+
try {
|
|
699
|
+
this.#controller(authority, now);
|
|
700
|
+
const row = this.#database.prepare('SELECT * FROM actions WHERE id = ?').get(actionId);
|
|
701
|
+
if (!row)
|
|
702
|
+
fail('state');
|
|
703
|
+
const record = this.#row(row);
|
|
704
|
+
if (record.status !== 'prepared' || record.version !== expected || record.expiresAt <= now)
|
|
705
|
+
fail('state');
|
|
706
|
+
const head = this.#currentGrant(record.grantId);
|
|
707
|
+
if (!head || head.revoked || head.revision !== record.grantRevision)
|
|
708
|
+
fail('grant');
|
|
709
|
+
const grant = this.#grantRow(head);
|
|
710
|
+
if (grant.expiresAt <= now)
|
|
711
|
+
fail('grant');
|
|
712
|
+
const changed = this.#database.prepare("UPDATE actions SET status = 'dispatched', version = version + 1 WHERE id = ? AND status = 'prepared' AND version = ?").run(actionId, expected);
|
|
713
|
+
if (changed.changes !== 1)
|
|
714
|
+
fail('state');
|
|
715
|
+
this.#audit('action-dispatched', actionId, expected + 1, now);
|
|
716
|
+
const updated = this.#database.prepare('SELECT * FROM actions WHERE id = ?').get(actionId);
|
|
717
|
+
this.#database.exec('COMMIT');
|
|
718
|
+
return this.#row(updated);
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
try {
|
|
722
|
+
this.#database.exec('ROLLBACK');
|
|
723
|
+
}
|
|
724
|
+
catch { }
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
settle(id, version, result, authority) {
|
|
729
|
+
const actionId = text(id);
|
|
730
|
+
const expected = integer(version, 1);
|
|
731
|
+
const outcome = resultInput(result);
|
|
732
|
+
const now = this.#time();
|
|
733
|
+
if (outcome.actionId !== actionId)
|
|
734
|
+
fail('invalid-input');
|
|
735
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
736
|
+
try {
|
|
737
|
+
this.#controller(authority, now);
|
|
738
|
+
const row = this.#database.prepare('SELECT * FROM actions WHERE id = ?').get(actionId);
|
|
739
|
+
if (!row)
|
|
740
|
+
fail('state');
|
|
741
|
+
const record = this.#row(row);
|
|
742
|
+
if (record.version !== expected || !['prepared', 'dispatched'].includes(record.status))
|
|
743
|
+
fail('state');
|
|
744
|
+
if (record.status === 'prepared' && outcome.status === 'succeeded')
|
|
745
|
+
fail('state');
|
|
746
|
+
const changed = this.#database.prepare('UPDATE actions SET status = ?, version = version + 1, result_json = ? WHERE id = ? AND version = ? AND status IN (\'prepared\', \'dispatched\')').run(outcome.status, stableJson(outcome), actionId, expected);
|
|
747
|
+
if (changed.changes !== 1)
|
|
748
|
+
fail('state');
|
|
749
|
+
this.#audit('action-settled', actionId, expected + 1, now);
|
|
750
|
+
const updated = this.#database.prepare('SELECT * FROM actions WHERE id = ?').get(actionId);
|
|
751
|
+
this.#database.exec('COMMIT');
|
|
752
|
+
return this.#row(updated);
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
try {
|
|
756
|
+
this.#database.exec('ROLLBACK');
|
|
757
|
+
}
|
|
758
|
+
catch { }
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
prepareCompensation(input) {
|
|
763
|
+
const args = object(input, ['identity', 'sessionId', 'request', 'authority'], ['leaseMs']);
|
|
764
|
+
const identity = identityInput(args.identity);
|
|
765
|
+
const sessionId = text(args.sessionId);
|
|
766
|
+
const request = compensationInput(args.request);
|
|
767
|
+
const lease = own(args, 'leaseMs') ? integer(args.leaseMs, 1, 30_000) : 30_000;
|
|
768
|
+
const now = this.#time();
|
|
769
|
+
const requestDigest = createHash('sha256').update(stableJson(request)).digest('hex');
|
|
770
|
+
const idempotencyDigest = createHash('sha256').update(request.idempotencyKey).digest('hex');
|
|
771
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
772
|
+
try {
|
|
773
|
+
this.#controller(args.authority, now);
|
|
774
|
+
const keyed = this.#database.prepare('SELECT * FROM compensations WHERE identity_json = ? AND session_id = ? AND grant_id = ? AND idempotency_digest = ?').get(stableJson(identity), sessionId, request.grantId, idempotencyDigest);
|
|
775
|
+
if (keyed) {
|
|
776
|
+
if (keyed.request_digest !== requestDigest)
|
|
777
|
+
fail('conflict');
|
|
778
|
+
this.#database.exec('COMMIT');
|
|
779
|
+
return { record: this.#compensationRow(keyed), created: false };
|
|
780
|
+
}
|
|
781
|
+
const previous = this.#database.prepare('SELECT request_digest FROM compensations WHERE forward_action_id = ?').get(request.forwardActionId);
|
|
782
|
+
if (previous)
|
|
783
|
+
fail('conflict');
|
|
784
|
+
const forwardRow = this.#database.prepare('SELECT * FROM actions WHERE id = ?').get(request.forwardActionId);
|
|
785
|
+
if (!forwardRow)
|
|
786
|
+
fail('state');
|
|
787
|
+
const forward = this.#row(forwardRow);
|
|
788
|
+
if (forward.kind !== 'commit' || forward.status !== 'succeeded' || forward.version !== request.forwardActionVersion || forward.requestDigest !== request.forwardRequestDigest
|
|
789
|
+
|| forward.result?.commitOid !== request.forwardCommitOid || forward.grantId !== request.grantId || forward.sessionId !== sessionId || !forward.paths || !equal(forward.identity, identity))
|
|
790
|
+
fail('state');
|
|
791
|
+
const grantRow = this.#currentGrant(request.grantId);
|
|
792
|
+
if (!grantRow || grantRow.revoked)
|
|
793
|
+
fail('grant');
|
|
794
|
+
const grant = this.#grantRow(grantRow);
|
|
795
|
+
const rollback = grant.rollback;
|
|
796
|
+
if (!rollback || grant.expiresAt <= now || !equal(identity, { principalDigest: grant.principalDigest, principalRecordId: grant.principalRecordId, principalVersion: grant.principalVersion, workspace: grant.workspace, agentPreset: grant.agentPreset })
|
|
797
|
+
|| grant.repository !== forwardRow.repository || grant.branch !== forwardRow.branch || !forward.paths.every(path => grant.paths.includes(path)))
|
|
798
|
+
fail('grant');
|
|
799
|
+
const used = this.#database.prepare('SELECT COUNT(*) AS count, COALESCE(SUM(bytes), 0) AS bytes FROM compensations WHERE identity_json = ? AND grant_id = ? AND budget_id = ?').get(stableJson(identity), grant.id, rollback.budgetId);
|
|
800
|
+
const records = this.#database.prepare('SELECT COUNT(*) AS count FROM compensations').get().count;
|
|
801
|
+
if (records >= maxRecords || used.count >= rollback.maxActions || used.bytes > rollback.maxTotalBytes)
|
|
802
|
+
fail('limit');
|
|
803
|
+
const expiresAt = Math.min(after(now, lease), grant.expiresAt);
|
|
804
|
+
const id = randomUUID();
|
|
805
|
+
this.#database.prepare(`INSERT INTO compensations(id, forward_action_id, forward_action_version, forward_grant_revision, identity_json, session_id, grant_id, grant_revision, repository, branch, paths_json, parent_oid, idempotency_digest, forward_request_digest, forward_commit_oid, request_digest, preimage_digest, preimage_json, budget_id, budget_max_actions, budget_max_total_bytes, bytes, expires_at, status, version, result_json)
|
|
806
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, 0, ?, 'capturing', 1, NULL)`).run(id, forward.id, forward.version, forward.grantRevision, stableJson(identity), sessionId, grant.id, grant.revision, grant.repository, grant.branch, stableJson(forward.paths), forwardRow.expected_head_oid, idempotencyDigest, forward.requestDigest, request.forwardCommitOid, requestDigest, rollback.budgetId, rollback.maxActions, rollback.maxTotalBytes, expiresAt);
|
|
807
|
+
this.#audit('compensation-capturing', id, 1, now);
|
|
808
|
+
const row = this.#database.prepare('SELECT * FROM compensations WHERE id = ?').get(id);
|
|
809
|
+
this.#database.exec('COMMIT');
|
|
810
|
+
return { record: this.#compensationRow(row), created: true };
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
try {
|
|
814
|
+
this.#database.exec('ROLLBACK');
|
|
815
|
+
}
|
|
816
|
+
catch { }
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
captureCompensation(id, version, value, authority) {
|
|
821
|
+
const compensationId = text(id);
|
|
822
|
+
const expected = integer(version, 1);
|
|
823
|
+
const preimage = compensationPreimage(value);
|
|
824
|
+
const now = this.#time();
|
|
825
|
+
const bytes = compensationBytes(preimage);
|
|
826
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
827
|
+
try {
|
|
828
|
+
this.#controller(authority, now);
|
|
829
|
+
const row = this.#database.prepare('SELECT * FROM compensations WHERE id = ?').get(compensationId);
|
|
830
|
+
if (!row)
|
|
831
|
+
fail('state');
|
|
832
|
+
const record = this.#compensationRow(row);
|
|
833
|
+
if (record.status !== 'capturing' || record.version !== expected || record.expiresAt <= now || preimage.repository !== record.repository || preimage.branch !== record.branch || preimage.commitOid !== record.parentOid
|
|
834
|
+
|| !equal(preimage.files.map(file => file.path), record.paths))
|
|
835
|
+
fail('state');
|
|
836
|
+
const head = this.#currentGrant(record.grantId);
|
|
837
|
+
if (!head || head.revoked || head.revision !== record.grantRevision)
|
|
838
|
+
fail('grant');
|
|
839
|
+
const grant = this.#grantRow(head);
|
|
840
|
+
if (grant.expiresAt <= now || !grant.rollback)
|
|
841
|
+
fail('grant');
|
|
842
|
+
const used = this.#database.prepare('SELECT COALESCE(SUM(bytes), 0) AS bytes FROM compensations WHERE identity_json = ? AND grant_id = ? AND budget_id = ? AND id != ?').get(stableJson(record.identity), record.grantId, row.budget_id, record.id);
|
|
843
|
+
if (!Number.isSafeInteger(used.bytes) || used.bytes + bytes > row.budget_max_total_bytes)
|
|
844
|
+
fail('limit');
|
|
845
|
+
const serialized = stableJson(preimage);
|
|
846
|
+
const preimageDigest = createHash('sha256').update(serialized).digest('hex');
|
|
847
|
+
const changed = this.#database.prepare("UPDATE compensations SET status = 'prepared', version = version + 1, preimage_digest = ?, preimage_json = ?, bytes = ? WHERE id = ? AND status = 'capturing' AND version = ?").run(preimageDigest, serialized, bytes, compensationId, expected);
|
|
848
|
+
if (changed.changes !== 1)
|
|
849
|
+
fail('state');
|
|
850
|
+
this.#audit('compensation-prepared', compensationId, expected + 1, now);
|
|
851
|
+
const updated = this.#database.prepare('SELECT * FROM compensations WHERE id = ?').get(compensationId);
|
|
852
|
+
this.#database.exec('COMMIT');
|
|
853
|
+
return this.#compensationRow(updated);
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
try {
|
|
857
|
+
this.#database.exec('ROLLBACK');
|
|
858
|
+
}
|
|
859
|
+
catch { }
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
dispatchCompensation(id, version, authority) {
|
|
864
|
+
const compensationId = text(id);
|
|
865
|
+
const expected = integer(version, 1);
|
|
866
|
+
const now = this.#time();
|
|
867
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
868
|
+
try {
|
|
869
|
+
this.#controller(authority, now);
|
|
870
|
+
const row = this.#database.prepare('SELECT * FROM compensations WHERE id = ?').get(compensationId);
|
|
871
|
+
if (!row)
|
|
872
|
+
fail('state');
|
|
873
|
+
const record = this.#compensationRow(row);
|
|
874
|
+
if (record.status !== 'prepared' || record.version !== expected || record.expiresAt <= now)
|
|
875
|
+
fail('state');
|
|
876
|
+
const head = this.#currentGrant(record.grantId);
|
|
877
|
+
if (!head || head.revoked || head.revision !== record.grantRevision || this.#grantRow(head).expiresAt <= now)
|
|
878
|
+
fail('grant');
|
|
879
|
+
const changed = this.#database.prepare("UPDATE compensations SET status = 'dispatched', version = version + 1 WHERE id = ? AND status = 'prepared' AND version = ?").run(compensationId, expected);
|
|
880
|
+
if (changed.changes !== 1)
|
|
881
|
+
fail('state');
|
|
882
|
+
this.#audit('compensation-dispatched', compensationId, expected + 1, now);
|
|
883
|
+
const updated = this.#database.prepare('SELECT * FROM compensations WHERE id = ?').get(compensationId);
|
|
884
|
+
this.#database.exec('COMMIT');
|
|
885
|
+
return this.#compensationRow(updated);
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
try {
|
|
889
|
+
this.#database.exec('ROLLBACK');
|
|
890
|
+
}
|
|
891
|
+
catch { }
|
|
892
|
+
throw error;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
settleCompensation(id, version, result, authority) {
|
|
896
|
+
const compensationId = text(id);
|
|
897
|
+
const expected = integer(version, 1);
|
|
898
|
+
const outcome = compensationResultInput(result);
|
|
899
|
+
const now = this.#time();
|
|
900
|
+
if (outcome.actionId !== compensationId)
|
|
901
|
+
fail('invalid-input');
|
|
902
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
903
|
+
try {
|
|
904
|
+
this.#controller(authority, now);
|
|
905
|
+
const row = this.#database.prepare('SELECT * FROM compensations WHERE id = ?').get(compensationId);
|
|
906
|
+
if (!row)
|
|
907
|
+
fail('state');
|
|
908
|
+
const record = this.#compensationRow(row);
|
|
909
|
+
if (record.status !== 'dispatched' || record.version !== expected || outcome.repository !== record.repository || outcome.branch !== record.branch
|
|
910
|
+
|| outcome.parentOid !== record.forwardCommitOid || outcome.actionMarker !== `dsh-compensation:${record.id}`)
|
|
911
|
+
fail('state');
|
|
912
|
+
const changed = this.#database.prepare("UPDATE compensations SET status = ?, version = version + 1, result_json = ? WHERE id = ? AND status = 'dispatched' AND version = ?").run(outcome.status, stableJson(outcome), compensationId, expected);
|
|
913
|
+
if (changed.changes !== 1)
|
|
914
|
+
fail('state');
|
|
915
|
+
this.#audit('compensation-settled', compensationId, expected + 1, now);
|
|
916
|
+
const updated = this.#database.prepare('SELECT * FROM compensations WHERE id = ?').get(compensationId);
|
|
917
|
+
this.#database.exec('COMMIT');
|
|
918
|
+
return this.#compensationRow(updated);
|
|
919
|
+
}
|
|
920
|
+
catch (error) {
|
|
921
|
+
try {
|
|
922
|
+
this.#database.exec('ROLLBACK');
|
|
923
|
+
}
|
|
924
|
+
catch { }
|
|
925
|
+
throw error;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
getCompensation(id) { const row = this.#database.prepare('SELECT * FROM compensations WHERE id = ?').get(text(id)); return row && this.#compensationRow(row); }
|
|
929
|
+
discardCompensation(id, version, authority) {
|
|
930
|
+
const compensationId = text(id);
|
|
931
|
+
const expected = integer(version, 1);
|
|
932
|
+
const now = this.#time();
|
|
933
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
934
|
+
try {
|
|
935
|
+
this.#controller(authority, now);
|
|
936
|
+
const changed = this.#database.prepare("DELETE FROM compensations WHERE id = ? AND status = 'capturing' AND version = ?").run(compensationId, expected);
|
|
937
|
+
if (changed.changes > 1)
|
|
938
|
+
fail('state');
|
|
939
|
+
if (changed.changes === 1)
|
|
940
|
+
this.#audit('compensation-discarded', compensationId, expected, now);
|
|
941
|
+
this.#database.exec('COMMIT');
|
|
942
|
+
return changed.changes === 1;
|
|
943
|
+
}
|
|
944
|
+
catch (error) {
|
|
945
|
+
try {
|
|
946
|
+
this.#database.exec('ROLLBACK');
|
|
947
|
+
}
|
|
948
|
+
catch { }
|
|
949
|
+
throw error;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Live counterpart of the prepared-row branch in recoverCompensations. A
|
|
954
|
+
* sealed prepared compensation whose authorization/hold ends *before* the
|
|
955
|
+
* local dispatch flip can never have sent a POST, so it is terminalized to
|
|
956
|
+
* unknown (never replayed) instead of being left as a zombie 'prepared' that
|
|
957
|
+
* only the next process restart would resolve. CAS on the exact prepared
|
|
958
|
+
* version keeps a racing dispatch authoritative. Returns undefined if the row
|
|
959
|
+
* is no longer a still-fresh prepared row at the expected version.
|
|
960
|
+
*/
|
|
961
|
+
abandonPreparedCompensation(id, version, authority, reason) {
|
|
962
|
+
const compensationId = text(id);
|
|
963
|
+
const expected = integer(version, 1);
|
|
964
|
+
const now = this.#time();
|
|
965
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
966
|
+
try {
|
|
967
|
+
this.#controller(authority, now);
|
|
968
|
+
const row = this.#database.prepare('SELECT * FROM compensations WHERE id = ?').get(compensationId);
|
|
969
|
+
if (!row)
|
|
970
|
+
fail('state');
|
|
971
|
+
const record = this.#compensationRow(row);
|
|
972
|
+
if (record.status !== 'prepared' || record.version !== expected) {
|
|
973
|
+
this.#database.exec('COMMIT');
|
|
974
|
+
return undefined;
|
|
975
|
+
}
|
|
976
|
+
const result = { actionId: record.id, status: 'unknown', repository: record.repository, branch: record.branch, parentOid: record.forwardCommitOid, actionMarker: `dsh-compensation:${record.id}`, reason: text(reason) };
|
|
977
|
+
const changed = this.#database.prepare("UPDATE compensations SET status = 'unknown', version = version + 1, result_json = ? WHERE id = ? AND status = 'prepared' AND version = ?").run(stableJson(result), compensationId, expected);
|
|
978
|
+
if (changed.changes !== 1)
|
|
979
|
+
fail('state');
|
|
980
|
+
this.#audit('compensation-abandoned', compensationId, expected + 1, now);
|
|
981
|
+
const updated = this.#database.prepare('SELECT * FROM compensations WHERE id = ?').get(compensationId);
|
|
982
|
+
this.#database.exec('COMMIT');
|
|
983
|
+
return this.#compensationRow(updated);
|
|
984
|
+
}
|
|
985
|
+
catch (error) {
|
|
986
|
+
try {
|
|
987
|
+
this.#database.exec('ROLLBACK');
|
|
988
|
+
}
|
|
989
|
+
catch { }
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
recoverCompensations(authority) {
|
|
994
|
+
const now = this.#time();
|
|
995
|
+
const neverDispatched = [];
|
|
996
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
997
|
+
try {
|
|
998
|
+
this.#controller(authority, now);
|
|
999
|
+
const rows = this.#database.prepare("SELECT * FROM compensations WHERE status IN ('capturing', 'prepared', 'dispatched')").all();
|
|
1000
|
+
for (const row of rows) {
|
|
1001
|
+
const record = this.#compensationRow(row);
|
|
1002
|
+
if (record.status === 'capturing') {
|
|
1003
|
+
// A capturing row carries no preimage and can never have reached the
|
|
1004
|
+
// dispatch POST; drop it so the forward action, idempotency key and
|
|
1005
|
+
// action budget are released for a fresh attempt.
|
|
1006
|
+
const changed = this.#database.prepare("DELETE FROM compensations WHERE id = ? AND status = 'capturing' AND version = ?").run(record.id, record.version);
|
|
1007
|
+
if (changed.changes !== 1)
|
|
1008
|
+
fail('state');
|
|
1009
|
+
this.#audit('compensation-discarded', record.id, record.version, now);
|
|
1010
|
+
neverDispatched.push({ id: record.id, identity: record.identity, budgetId: text(row.budget_id) });
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
const result = { actionId: record.id, status: 'unknown', repository: record.repository, branch: record.branch, parentOid: record.forwardCommitOid, actionMarker: `dsh-compensation:${record.id}`, reason: 'controller-recovery-no-replay' };
|
|
1014
|
+
const changed = this.#database.prepare("UPDATE compensations SET status = 'unknown', version = version + 1, result_json = ? WHERE id = ? AND status IN ('prepared', 'dispatched') AND version = ?").run(stableJson(result), record.id, record.version);
|
|
1015
|
+
if (changed.changes !== 1)
|
|
1016
|
+
fail('state');
|
|
1017
|
+
this.#audit('compensation-recovered', record.id, record.version + 1, now);
|
|
1018
|
+
// Prepared rows sealed a preimage but never flipped to dispatched, so
|
|
1019
|
+
// no POST could have been sent; their reservation is still open.
|
|
1020
|
+
if (record.status === 'prepared')
|
|
1021
|
+
neverDispatched.push({ id: record.id, identity: record.identity, budgetId: text(row.budget_id) });
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
this.#database.exec('COMMIT');
|
|
1025
|
+
return { count: rows.length, neverDispatched };
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
try {
|
|
1029
|
+
this.#database.exec('ROLLBACK');
|
|
1030
|
+
}
|
|
1031
|
+
catch { }
|
|
1032
|
+
throw error;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
usable(id) { const row = this.#database.prepare('SELECT * FROM actions WHERE id = ?').get(text(id)); if (!row)
|
|
1036
|
+
return false; const record = this.#row(row); if (!['prepared', 'dispatched'].includes(record.status) || record.expiresAt <= this.#time())
|
|
1037
|
+
return false; const head = this.#currentGrant(record.grantId); return !!head && !head.revoked && head.revision === record.grantRevision && this.#grantRow(head).expiresAt > this.#time(); }
|
|
1038
|
+
recover(authority) {
|
|
1039
|
+
const now = this.#time();
|
|
1040
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1041
|
+
try {
|
|
1042
|
+
this.#controller(authority, now);
|
|
1043
|
+
const rows = this.#database.prepare("SELECT * FROM actions WHERE status IN ('prepared', 'dispatched')").all();
|
|
1044
|
+
for (const row of rows) {
|
|
1045
|
+
const record = this.#row(row);
|
|
1046
|
+
const result = { actionId: record.id, status: 'unknown', reason: 'controller-recovery-no-replay' };
|
|
1047
|
+
this.#database.prepare("UPDATE actions SET status = 'unknown', version = version + 1, result_json = ? WHERE id = ? AND version = ? AND status IN ('prepared', 'dispatched')").run(stableJson(result), record.id, record.version);
|
|
1048
|
+
this.#audit('action-recovered', record.id, record.version + 1, now);
|
|
1049
|
+
}
|
|
1050
|
+
this.#database.exec('COMMIT');
|
|
1051
|
+
return rows.length;
|
|
1052
|
+
}
|
|
1053
|
+
catch (error) {
|
|
1054
|
+
try {
|
|
1055
|
+
this.#database.exec('ROLLBACK');
|
|
1056
|
+
}
|
|
1057
|
+
catch { }
|
|
1058
|
+
throw error;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
get(id) { const row = this.#database.prepare('SELECT * FROM actions WHERE id = ?').get(text(id)); return row && this.#row(row); }
|
|
1062
|
+
close() { this.#database.close(); }
|
|
1063
|
+
}
|
|
1064
|
+
//# sourceMappingURL=ledger.js.map
|