@dsh-enhanced/assistant-isolation 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 +171 -0
- package/cordis.patch.yml +3 -0
- package/lib/audit-archive.d.ts +52 -0
- package/lib/audit-archive.d.ts.map +1 -0
- package/lib/audit-archive.js +775 -0
- package/lib/audit-archive.js.map +1 -0
- package/lib/cleanup-receipt.d.ts +26 -0
- package/lib/cleanup-receipt.d.ts.map +1 -0
- package/lib/cleanup-receipt.js +131 -0
- package/lib/cleanup-receipt.js.map +1 -0
- package/lib/cli.d.ts +16 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +183 -0
- package/lib/cli.js.map +1 -0
- package/lib/config.d.ts +20 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +61 -0
- package/lib/config.js.map +1 -0
- package/lib/daemon-binding.d.ts +13 -0
- package/lib/daemon-binding.d.ts.map +1 -0
- package/lib/daemon-binding.js +120 -0
- package/lib/daemon-binding.js.map +1 -0
- package/lib/diagnostics.d.ts +19 -0
- package/lib/diagnostics.d.ts.map +1 -0
- package/lib/diagnostics.js +122 -0
- package/lib/diagnostics.js.map +1 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -0
- package/lib/ledger.d.ts +88 -0
- package/lib/ledger.d.ts.map +1 -0
- package/lib/ledger.js +623 -0
- package/lib/ledger.js.map +1 -0
- package/lib/probe.d.ts +5 -0
- package/lib/probe.d.ts.map +1 -0
- package/lib/probe.js +45 -0
- package/lib/probe.js.map +1 -0
- package/lib/reconcile.d.ts +5 -0
- package/lib/reconcile.d.ts.map +1 -0
- package/lib/reconcile.js +29 -0
- package/lib/reconcile.js.map +1 -0
- package/lib/runner.d.ts +12 -0
- package/lib/runner.d.ts.map +1 -0
- package/lib/runner.js +201 -0
- package/lib/runner.js.map +1 -0
- package/lib/runtime-witness.d.ts +32 -0
- package/lib/runtime-witness.d.ts.map +1 -0
- package/lib/runtime-witness.js +225 -0
- package/lib/runtime-witness.js.map +1 -0
- package/lib/service.d.ts +47 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +361 -0
- package/lib/service.js.map +1 -0
- package/lib/storage-policy.d.ts +7 -0
- package/lib/storage-policy.d.ts.map +1 -0
- package/lib/storage-policy.js +82 -0
- package/lib/storage-policy.js.map +1 -0
- package/lib/storage.d.ts +18 -0
- package/lib/storage.d.ts.map +1 -0
- package/lib/storage.js +102 -0
- package/lib/storage.js.map +1 -0
- package/lib/tools.d.ts +4 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +49 -0
- package/lib/tools.js.map +1 -0
- package/lib/types.d.ts +143 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/verifier-runner.d.ts +21 -0
- package/lib/verifier-runner.d.ts.map +1 -0
- package/lib/verifier-runner.js +192 -0
- package/lib/verifier-runner.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/lib/workspace.d.ts +6 -0
- package/lib/workspace.d.ts.map +1 -0
- package/lib/workspace.js +140 -0
- package/lib/workspace.js.map +1 -0
- package/package.json +105 -0
- package/runtime/supervisor.mjs +290 -0
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { validateGoalArtifactAdmission } from '@dsh-enhanced/task-acceptance-contract';
|
|
2
|
+
import { chmodSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { dirname, isAbsolute, normalize } from 'node:path';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { processExited, sameDaemonWitness, validateCreationWitness } from './runtime-witness.js';
|
|
7
|
+
import { receiptData } from './cleanup-receipt.js';
|
|
8
|
+
import { pruneResult, validateRetention } from './storage-policy.js';
|
|
9
|
+
export class IsolationLedgerError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message = code) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = 'IsolationLedgerError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const schemaVersion = 6;
|
|
18
|
+
const outputMaximum = 1_048_576;
|
|
19
|
+
const artifactMaximum = 128;
|
|
20
|
+
const textMaximum = 16_384;
|
|
21
|
+
function fail(code, message) { throw new IsolationLedgerError(code, message); }
|
|
22
|
+
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
23
|
+
const safePositive = (value) => Number.isSafeInteger(value) && value > 0 && value <= Number.MAX_SAFE_INTEGER;
|
|
24
|
+
const safeTime = (value) => Number.isSafeInteger(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER;
|
|
25
|
+
const positiveTime = (value) => safeTime(value) && value > 0;
|
|
26
|
+
const text = (value, maximumLength = textMaximum) => typeof value === 'string' && value.length > 0 && value.length <= maximumLength;
|
|
27
|
+
const frozen = (value) => { if (value && typeof value === 'object') {
|
|
28
|
+
for (const entry of Object.values(value))
|
|
29
|
+
frozen(entry);
|
|
30
|
+
Object.freeze(value);
|
|
31
|
+
} ; return value; };
|
|
32
|
+
const onlyKeys = (value, allowed) => Object.keys(value).every(key => allowed.includes(key));
|
|
33
|
+
function identity(value) {
|
|
34
|
+
if (!value || typeof value !== 'object' || Object.keys(value).length !== 5
|
|
35
|
+
|| !text(value.principalDigest) || !text(value.principalRecordId) || !safePositive(value.principalVersion)
|
|
36
|
+
|| !text(value.workspace) || !text(value.agentPreset))
|
|
37
|
+
fail('invalid-input', 'invalid isolation identity');
|
|
38
|
+
return frozen({ principalDigest: value.principalDigest, principalRecordId: value.principalRecordId, principalVersion: value.principalVersion, workspace: value.workspace, agentPreset: value.agentPreset });
|
|
39
|
+
}
|
|
40
|
+
function grant(value) {
|
|
41
|
+
if (!value || typeof value !== 'object' || Object.keys(value).length !== 10 || !text(value.id) || !safePositive(value.revision)
|
|
42
|
+
|| !positiveTime(value.expiresAt) || !safePositive(value.maxRuns) || !safePositive(value.maxTotalDurationMs))
|
|
43
|
+
fail('invalid-input', 'invalid isolation grant');
|
|
44
|
+
return frozen({ ...identity({ principalDigest: value.principalDigest, principalRecordId: value.principalRecordId, principalVersion: value.principalVersion, workspace: value.workspace, agentPreset: value.agentPreset }), id: value.id, revision: value.revision, expiresAt: value.expiresAt, maxRuns: value.maxRuns, maxTotalDurationMs: value.maxTotalDurationMs });
|
|
45
|
+
}
|
|
46
|
+
function artifactBinding(value) {
|
|
47
|
+
if (!value || typeof value !== 'object' || Object.keys(value).length !== 2 || !onlyKeys(value, ['admission', 'paths'])
|
|
48
|
+
|| !Array.isArray(value.paths) || value.paths.length > 128 || new Set(value.paths).size !== value.paths.length
|
|
49
|
+
|| value.paths.some(path => !text(path, 4096) || isAbsolute(path) || normalize(path) !== path || path === '.' || path.split(/[\\/]/u).includes('..') || /[\p{Cc}]/u.test(path)))
|
|
50
|
+
fail('invalid-input', 'invalid artifact binding');
|
|
51
|
+
return frozen({ admission: validateGoalArtifactAdmission(value.admission), paths: [...value.paths] });
|
|
52
|
+
}
|
|
53
|
+
function grantDigest(value) { return JSON.stringify(value); }
|
|
54
|
+
function result(value, allowRetention = false) {
|
|
55
|
+
if (!value || typeof value !== 'object' || !text(value.jobId) || !['succeeded', 'failed', 'cancelled', 'timed-out', 'unknown'].includes(value.status)
|
|
56
|
+
|| typeof value.quiescent !== 'boolean' || typeof value.stdout !== 'string' || typeof value.stderr !== 'string'
|
|
57
|
+
|| value.stdout.length > outputMaximum || value.stderr.length > outputMaximum || !Array.isArray(value.artifacts) || value.artifacts.length > artifactMaximum
|
|
58
|
+
|| (value.exitCode !== undefined && (!Number.isSafeInteger(value.exitCode) || value.exitCode < -1_000_000 || value.exitCode > 1_000_000))
|
|
59
|
+
|| (value.reason !== undefined && (typeof value.reason !== 'string' || value.reason.length > textMaximum))
|
|
60
|
+
|| !value.artifacts.every(item => item && typeof item === 'object' && typeof item.path === 'string' && item.path.length <= textMaximum && typeof item.content === 'string' && item.content.length <= outputMaximum))
|
|
61
|
+
fail('invalid-input', 'invalid isolation result');
|
|
62
|
+
const retention = value.retention === undefined ? undefined : validateRetention(value.retention);
|
|
63
|
+
if (value.retention !== undefined && (!allowRetention || !retention || value.status === 'unknown' || !value.quiescent || value.stdout !== '' || value.stderr !== '' || value.artifacts.length !== 0))
|
|
64
|
+
fail('invalid-input', 'invalid pruned isolation result');
|
|
65
|
+
return frozen({ jobId: value.jobId, status: value.status, quiescent: value.quiescent, ...(value.exitCode === undefined ? {} : { exitCode: value.exitCode }), stdout: value.stdout, stderr: value.stderr, artifacts: value.artifacts.map(item => ({ path: item.path, content: item.content })), ...(value.reason === undefined ? {} : { reason: value.reason }), ...(retention ? { retention } : {}) });
|
|
66
|
+
}
|
|
67
|
+
function open(path) {
|
|
68
|
+
if (path !== ':memory:' && !isAbsolute(path))
|
|
69
|
+
fail('invalid-path', 'isolation ledger path must be absolute');
|
|
70
|
+
if (path !== ':memory:') {
|
|
71
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
72
|
+
chmodSync(dirname(path), 0o700);
|
|
73
|
+
}
|
|
74
|
+
const database = new DatabaseSync(path, { enableForeignKeyConstraints: true });
|
|
75
|
+
try {
|
|
76
|
+
database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA synchronous = FULL;');
|
|
77
|
+
if (path !== ':memory:') {
|
|
78
|
+
const row = database.prepare('PRAGMA journal_mode = WAL').get();
|
|
79
|
+
if (row.journal_mode.toLowerCase() !== 'wal')
|
|
80
|
+
fail('schema', 'isolation ledger refused WAL');
|
|
81
|
+
}
|
|
82
|
+
const version = database.prepare('PRAGMA user_version').get().user_version;
|
|
83
|
+
if (version > schemaVersion)
|
|
84
|
+
fail('schema-too-new');
|
|
85
|
+
if (version === 0)
|
|
86
|
+
database.exec(`PRAGMA auto_vacuum = INCREMENTAL; VACUUM; BEGIN IMMEDIATE;
|
|
87
|
+
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
|
88
|
+
INSERT INTO schema_meta(key, value) VALUES ('schema-version', '6');
|
|
89
|
+
CREATE TABLE isolation_grants (id TEXT PRIMARY KEY, digest TEXT NOT NULL, revision INTEGER NOT NULL, expires_at INTEGER NOT NULL, max_runs INTEGER NOT NULL, max_total_duration_ms INTEGER NOT NULL, revoked INTEGER NOT NULL CHECK(revoked IN (0,1)), revoke_reason TEXT, principal_digest TEXT NOT NULL, principal_record_id TEXT NOT NULL, principal_version INTEGER NOT NULL, workspace TEXT NOT NULL, agent_preset TEXT NOT NULL) STRICT;
|
|
90
|
+
CREATE TABLE isolation_jobs (id TEXT PRIMARY KEY, grant_id TEXT NOT NULL, grant_revision INTEGER NOT NULL, principal_digest TEXT NOT NULL, principal_record_id TEXT NOT NULL, principal_version INTEGER NOT NULL, workspace TEXT NOT NULL, agent_preset TEXT NOT NULL, session_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, request_digest TEXT NOT NULL, container_name TEXT NOT NULL UNIQUE, deadline INTEGER NOT NULL, reserved_duration_ms INTEGER NOT NULL, reserved_memory_mib INTEGER NOT NULL DEFAULT 0, reserved_workspace_inodes INTEGER NOT NULL DEFAULT 0, reserved_storage_bytes INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL CHECK(status IN ('prepared','running','succeeded','failed','cancelled','timed-out','unknown')), version INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, result_json TEXT, dispatch_attempted INTEGER NOT NULL DEFAULT 0 CHECK(dispatch_attempted IN (0,1)), creation_witness_json TEXT, artifact_binding_json TEXT, UNIQUE(principal_digest, principal_record_id, principal_version, workspace, agent_preset, session_id, grant_id, idempotency_key)) STRICT;
|
|
91
|
+
CREATE INDEX isolation_jobs_grant ON isolation_jobs(grant_id);
|
|
92
|
+
CREATE INDEX isolation_jobs_recoverable ON isolation_jobs(status, updated_at);
|
|
93
|
+
CREATE TABLE isolation_audit (sequence INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at INTEGER NOT NULL, action TEXT NOT NULL, job_id TEXT, grant_id TEXT, detail TEXT NOT NULL) STRICT;
|
|
94
|
+
CREATE TABLE isolation_controller (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), owner_id TEXT NOT NULL, fence INTEGER NOT NULL, expires_at INTEGER NOT NULL) STRICT;
|
|
95
|
+
PRAGMA user_version = 6; COMMIT;`);
|
|
96
|
+
if (version === 1)
|
|
97
|
+
database.exec(`BEGIN IMMEDIATE;
|
|
98
|
+
ALTER TABLE isolation_jobs ADD COLUMN reserved_memory_mib INTEGER NOT NULL DEFAULT 0;
|
|
99
|
+
ALTER TABLE isolation_jobs ADD COLUMN reserved_workspace_inodes INTEGER NOT NULL DEFAULT 0;
|
|
100
|
+
UPDATE schema_meta SET value='2' WHERE key='schema-version';
|
|
101
|
+
PRAGMA user_version = 2; COMMIT;`);
|
|
102
|
+
// Old prepared/running records cannot prove that no supervisor was spawned.
|
|
103
|
+
if (version === 1 || version === 2)
|
|
104
|
+
database.exec(`BEGIN IMMEDIATE;
|
|
105
|
+
ALTER TABLE isolation_jobs ADD COLUMN dispatch_attempted INTEGER NOT NULL DEFAULT 1 CHECK(dispatch_attempted IN (0,1));
|
|
106
|
+
ALTER TABLE isolation_jobs ADD COLUMN creation_witness_json TEXT;
|
|
107
|
+
UPDATE schema_meta SET value='3' WHERE key='schema-version';
|
|
108
|
+
PRAGMA user_version = 3; COMMIT;`);
|
|
109
|
+
if ([1, 2, 3].includes(version))
|
|
110
|
+
database.exec(`BEGIN IMMEDIATE; UPDATE schema_meta SET value='4' WHERE key='schema-version'; PRAGMA user_version=4; COMMIT;`);
|
|
111
|
+
if ([1, 2, 3, 4].includes(version))
|
|
112
|
+
database.exec(`BEGIN IMMEDIATE; ALTER TABLE isolation_jobs ADD COLUMN reserved_storage_bytes INTEGER NOT NULL DEFAULT 0; UPDATE schema_meta SET value='5' WHERE key='schema-version'; PRAGMA user_version=5; COMMIT;`);
|
|
113
|
+
if ([1, 2, 3, 4, 5].includes(version))
|
|
114
|
+
database.exec(`BEGIN IMMEDIATE; ALTER TABLE isolation_jobs ADD COLUMN artifact_binding_json TEXT; UPDATE schema_meta SET value='6' WHERE key='schema-version'; PRAGMA user_version=6; COMMIT;`);
|
|
115
|
+
database.exec("CREATE INDEX IF NOT EXISTS isolation_artifact_contract ON isolation_jobs(json_extract(artifact_binding_json, '$.admission.contractId')) WHERE artifact_binding_json IS NOT NULL");
|
|
116
|
+
if (version !== 0 && version !== 1 && version !== 2 && version !== 3 && version !== 4 && version !== 5 && version !== schemaVersion)
|
|
117
|
+
fail('schema');
|
|
118
|
+
if (path !== ':memory:')
|
|
119
|
+
chmodSync(path, 0o600);
|
|
120
|
+
return database;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
database.close();
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Durable Host-owned authorization and job state. This database is never mounted in workers. */
|
|
128
|
+
export class IsolationLedger {
|
|
129
|
+
#database;
|
|
130
|
+
#now;
|
|
131
|
+
constructor(path, { now = Date.now } = {}) { this.#database = open(path); this.#now = now; }
|
|
132
|
+
#transaction(operation) { this.#database.exec('BEGIN IMMEDIATE'); try {
|
|
133
|
+
const value = operation();
|
|
134
|
+
this.#database.exec('COMMIT');
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
try {
|
|
139
|
+
this.#database.exec('ROLLBACK');
|
|
140
|
+
}
|
|
141
|
+
catch { }
|
|
142
|
+
;
|
|
143
|
+
throw error;
|
|
144
|
+
} }
|
|
145
|
+
#nowValue() { const value = this.#now(); if (!safeTime(value))
|
|
146
|
+
fail('invalid-input', 'clock returned invalid time'); return value; }
|
|
147
|
+
#grant(id) { return this.#database.prepare('SELECT * FROM isolation_grants WHERE id = ?').get(id); }
|
|
148
|
+
#job(id) { const row = this.#database.prepare('SELECT * FROM isolation_jobs WHERE id = ?').get(id); return row ? this.#decodeJob(row) : undefined; }
|
|
149
|
+
#audit(now, action, jobId, grantId, detail) { this.#database.prepare('INSERT INTO isolation_audit(occurred_at, action, job_id, grant_id, detail) VALUES (?, ?, ?, ?, ?)').run(now, action, jobId, grantId, detail); }
|
|
150
|
+
#authority(value) {
|
|
151
|
+
if (value === undefined)
|
|
152
|
+
return undefined;
|
|
153
|
+
if (!value || typeof value !== 'object' || Object.keys(value).length !== 2 || !text(value.ownerId) || !safePositive(value.fence))
|
|
154
|
+
fail('invalid-input', 'invalid controller authority');
|
|
155
|
+
return frozen({ ownerId: value.ownerId, fence: value.fence });
|
|
156
|
+
}
|
|
157
|
+
#requireController(value, now) {
|
|
158
|
+
if (!value)
|
|
159
|
+
return;
|
|
160
|
+
const row = this.#database.prepare('SELECT owner_id, fence, expires_at FROM isolation_controller WHERE singleton=1').get();
|
|
161
|
+
if (!row || row.owner_id !== value.ownerId || row.fence !== value.fence || !safeTime(row.expires_at) || row.expires_at <= now)
|
|
162
|
+
fail('unauthorized', 'controller lease is absent, expired, or fenced');
|
|
163
|
+
}
|
|
164
|
+
#decodeJob(row) {
|
|
165
|
+
const jobIdentity = identity({ principalDigest: row.principal_digest, principalRecordId: row.principal_record_id, principalVersion: row.principal_version, workspace: row.workspace, agentPreset: row.agent_preset });
|
|
166
|
+
if (!text(row.id) || !text(row.grant_id) || !safePositive(row.grant_revision) || !text(row.session_id) || !text(row.idempotency_key) || !text(row.request_digest) || !text(row.container_name) || !positiveTime(row.deadline) || !safePositive(row.reserved_duration_ms) || !safeTime(row.reserved_memory_mib) || !safeTime(row.reserved_workspace_inodes) || !safeTime(row.reserved_storage_bytes) || !safePositive(row.version) || !safeTime(row.created_at) || !safeTime(row.updated_at) || row.updated_at < row.created_at || !['prepared', 'running', 'succeeded', 'failed', 'cancelled', 'timed-out', 'unknown'].includes(row.status))
|
|
167
|
+
fail('schema');
|
|
168
|
+
if (![0, 1].includes(row.dispatch_attempted))
|
|
169
|
+
fail('schema');
|
|
170
|
+
let witness;
|
|
171
|
+
if (row.creation_witness_json !== null) {
|
|
172
|
+
try {
|
|
173
|
+
witness = validateCreationWitness(JSON.parse(row.creation_witness_json));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
fail('schema');
|
|
177
|
+
}
|
|
178
|
+
if (!row.dispatch_attempted)
|
|
179
|
+
fail('schema');
|
|
180
|
+
}
|
|
181
|
+
let binding;
|
|
182
|
+
if (row.artifact_binding_json !== null) {
|
|
183
|
+
try {
|
|
184
|
+
binding = artifactBinding(JSON.parse(row.artifact_binding_json));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
fail('schema');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
let parsed;
|
|
191
|
+
if (row.result_json !== null) {
|
|
192
|
+
try {
|
|
193
|
+
parsed = result(JSON.parse(row.result_json), true);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
fail('schema');
|
|
197
|
+
}
|
|
198
|
+
;
|
|
199
|
+
if (parsed.jobId !== row.id || (row.status === 'prepared' || row.status === 'running') || parsed.status !== row.status)
|
|
200
|
+
fail('schema');
|
|
201
|
+
}
|
|
202
|
+
else if (row.status !== 'prepared' && row.status !== 'running')
|
|
203
|
+
fail('schema');
|
|
204
|
+
return frozen({ ...(binding ? { artifactBinding: binding } : {}), id: row.id, grantId: row.grant_id, grantRevision: row.grant_revision, identity: jobIdentity, sessionId: row.session_id, idempotencyKey: row.idempotency_key, requestDigest: row.request_digest, containerName: row.container_name, deadline: row.deadline, reservedDurationMs: row.reserved_duration_ms, reservedMemoryMiB: row.reserved_memory_mib, reservedWorkspaceInodes: row.reserved_workspace_inodes, reservedStorageBytes: row.reserved_storage_bytes, dispatchAttempted: row.dispatch_attempted === 1, ...(witness ? { creationWitness: witness } : {}), status: row.status, version: row.version, createdAt: row.created_at, updatedAt: row.updated_at, ...(parsed ? { result: parsed } : {}) });
|
|
205
|
+
}
|
|
206
|
+
syncGrants(grants, authority) {
|
|
207
|
+
if (!Array.isArray(grants))
|
|
208
|
+
fail('invalid-input');
|
|
209
|
+
const values = grants.map(grant);
|
|
210
|
+
const controller = this.#authority(authority);
|
|
211
|
+
const ids = new Set();
|
|
212
|
+
if (values.some(item => ids.has(item.id) || !ids.add(item.id)))
|
|
213
|
+
fail('invalid-input', 'duplicate grant');
|
|
214
|
+
this.#transaction(() => {
|
|
215
|
+
const now = this.#nowValue();
|
|
216
|
+
this.#requireController(controller, now);
|
|
217
|
+
const seen = new Set(values.map(item => item.id));
|
|
218
|
+
for (const value of values) {
|
|
219
|
+
const current = this.#grant(value.id);
|
|
220
|
+
const digest = grantDigest(value);
|
|
221
|
+
if (current && value.revision < current.revision)
|
|
222
|
+
fail('conflict', 'grant revision is stale');
|
|
223
|
+
if (current && value.revision === current.revision) {
|
|
224
|
+
if (current.digest !== digest)
|
|
225
|
+
fail('conflict', 'grant revision digest differs');
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (current)
|
|
229
|
+
this.#database.prepare('UPDATE isolation_grants SET digest=?, revision=?, expires_at=?, max_runs=?, max_total_duration_ms=?, revoked=0, revoke_reason=NULL, principal_digest=?, principal_record_id=?, principal_version=?, workspace=?, agent_preset=? WHERE id=?').run(digest, value.revision, value.expiresAt, value.maxRuns, value.maxTotalDurationMs, value.principalDigest, value.principalRecordId, value.principalVersion, value.workspace, value.agentPreset, value.id);
|
|
230
|
+
else
|
|
231
|
+
this.#database.prepare('INSERT INTO isolation_grants(id,digest,revision,expires_at,max_runs,max_total_duration_ms,revoked,principal_digest,principal_record_id,principal_version,workspace,agent_preset) VALUES (?,?,?,?,?,?,0,?,?,?,?,?)').run(value.id, digest, value.revision, value.expiresAt, value.maxRuns, value.maxTotalDurationMs, value.principalDigest, value.principalRecordId, value.principalVersion, value.workspace, value.agentPreset);
|
|
232
|
+
this.#audit(now, 'grant-synced', null, value.id, `revision:${value.revision}`);
|
|
233
|
+
}
|
|
234
|
+
for (const row of this.#database.prepare('SELECT id, revision, revoked FROM isolation_grants').all())
|
|
235
|
+
if (!seen.has(row.id) && row.revoked === 0) {
|
|
236
|
+
this.#database.prepare('UPDATE isolation_grants SET revoked=1, revoke_reason=? WHERE id=?').run('removed-from-config', row.id);
|
|
237
|
+
this.#audit(now, 'grant-revoked', null, row.id, 'removed-from-config');
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
prepare(input) {
|
|
242
|
+
if (!input || typeof input !== 'object' || ![6, 7, 8, 9, 10, 11].includes(Object.keys(input).length) || !onlyKeys(input, ['identity', 'sessionId', 'grantId', 'idempotencyKey', 'requestDigest', 'durationMs', 'maxActiveJobs', 'authority', 'resourceReservation', 'storageBudget', 'artifactBinding']) || !text(input.sessionId) || !text(input.grantId) || !text(input.idempotencyKey) || !text(input.requestDigest) || !safePositive(input.durationMs) || (input.maxActiveJobs !== undefined && !safePositive(input.maxActiveJobs)))
|
|
243
|
+
fail('invalid-input');
|
|
244
|
+
const binding = input.artifactBinding === undefined ? undefined : artifactBinding(input.artifactBinding);
|
|
245
|
+
const reservation = input.resourceReservation === undefined ? undefined : input.resourceReservation;
|
|
246
|
+
if (reservation !== undefined && (!reservation || typeof reservation !== 'object' || Object.keys(reservation).length !== 4 || !onlyKeys(reservation, ['memoryMiB', 'workspaceInodes', 'maxMemoryMiB', 'maxWorkspaceInodes']) || !safePositive(reservation.memoryMiB) || !safePositive(reservation.workspaceInodes) || !safePositive(reservation.maxMemoryMiB) || !safePositive(reservation.maxWorkspaceInodes) || reservation.memoryMiB > reservation.maxMemoryMiB || reservation.workspaceInodes > reservation.maxWorkspaceInodes))
|
|
247
|
+
fail('invalid-input', 'invalid resource reservation');
|
|
248
|
+
const storage = input.storageBudget;
|
|
249
|
+
if (storage !== undefined && (!storage || typeof storage !== 'object' || !onlyKeys(storage, ['maxStateBytes', 'maxJobRecords', 'reservedBytes', 'observation']) || ![3, 4].includes(Object.keys(storage).length) || !safePositive(storage.maxStateBytes) || !safePositive(storage.maxJobRecords) || !safePositive(storage.reservedBytes) || (storage.observation !== undefined && (!storage.observation || typeof storage.observation !== 'object' || Object.keys(storage.observation).length !== 2 || !onlyKeys(storage.observation, ['bytes', 'observedAt']) || !safeTime(storage.observation.bytes) || !safeTime(storage.observation.observedAt)))))
|
|
250
|
+
fail('invalid-input', 'invalid storage budget');
|
|
251
|
+
const who = identity(input.identity);
|
|
252
|
+
const controller = this.#authority(input.authority);
|
|
253
|
+
return this.#transaction(() => {
|
|
254
|
+
const now = this.#nowValue();
|
|
255
|
+
this.#requireController(controller, now);
|
|
256
|
+
const existing = this.#database.prepare('SELECT * FROM isolation_jobs WHERE principal_digest=? AND principal_record_id=? AND principal_version=? AND workspace=? AND agent_preset=? AND session_id=? AND grant_id=? AND idempotency_key=?').get(who.principalDigest, who.principalRecordId, who.principalVersion, who.workspace, who.agentPreset, input.sessionId, input.grantId, input.idempotencyKey);
|
|
257
|
+
if (existing) {
|
|
258
|
+
const job = this.#decodeJob(existing);
|
|
259
|
+
if (job.requestDigest !== input.requestDigest)
|
|
260
|
+
fail('conflict', 'idempotency key has a different request digest');
|
|
261
|
+
return { job, created: false };
|
|
262
|
+
}
|
|
263
|
+
const current = this.#grant(input.grantId);
|
|
264
|
+
if (!current || current.revoked || current.expires_at <= now || current.principal_digest !== who.principalDigest || current.principal_record_id !== who.principalRecordId || current.principal_version !== who.principalVersion || current.workspace !== who.workspace || current.agent_preset !== who.agentPreset)
|
|
265
|
+
fail('unauthorized');
|
|
266
|
+
if (input.maxActiveJobs !== undefined) {
|
|
267
|
+
const active = this.#database.prepare("SELECT COUNT(*) AS count FROM isolation_jobs WHERE status IN ('prepared','running') OR (result_json IS NOT NULL AND json_extract(result_json, '$.quiescent') = 0)").get();
|
|
268
|
+
if (!safeTime(active.count) || active.count >= input.maxActiveJobs)
|
|
269
|
+
fail('unauthorized', 'active isolation job limit reached');
|
|
270
|
+
}
|
|
271
|
+
const usage = this.#database.prepare('SELECT COUNT(*) AS runs, COALESCE(SUM(reserved_duration_ms), 0) AS duration FROM isolation_jobs WHERE grant_id=?').get(input.grantId);
|
|
272
|
+
if (!safeTime(usage.runs) || !safeTime(usage.duration) || usage.runs >= current.max_runs || input.durationMs > current.max_total_duration_ms || usage.duration > current.max_total_duration_ms - input.durationMs)
|
|
273
|
+
fail('unauthorized', 'grant budget exhausted');
|
|
274
|
+
if (reservation) {
|
|
275
|
+
const occupied = "status IN ('prepared','running') OR (result_json IS NOT NULL AND json_extract(result_json, '$.quiescent') = 0)";
|
|
276
|
+
const legacy = this.#database.prepare(`SELECT COUNT(*) AS count FROM isolation_jobs WHERE (${occupied}) AND (reserved_memory_mib = 0 OR reserved_workspace_inodes = 0)`).get();
|
|
277
|
+
if (!safeTime(legacy.count) || legacy.count > 0)
|
|
278
|
+
fail('unauthorized', 'active legacy job has no resource reservation');
|
|
279
|
+
const resources = this.#database.prepare(`SELECT COALESCE(SUM(reserved_memory_mib), 0) AS memory, COALESCE(SUM(reserved_workspace_inodes), 0) AS inodes FROM isolation_jobs WHERE ${occupied}`).get();
|
|
280
|
+
if (!safeTime(resources.memory) || !safeTime(resources.inodes) || resources.memory > reservation.maxMemoryMiB - reservation.memoryMiB || resources.inodes > reservation.maxWorkspaceInodes - reservation.workspaceInodes)
|
|
281
|
+
fail('unauthorized', 'isolation resource pool exhausted');
|
|
282
|
+
}
|
|
283
|
+
if (storage) {
|
|
284
|
+
const observation = storage.observation;
|
|
285
|
+
if (!observation)
|
|
286
|
+
fail('unauthorized', 'storage observation unavailable');
|
|
287
|
+
if (observation.observedAt > now || now - observation.observedAt > 5_000)
|
|
288
|
+
fail('invalid-input', 'storage observation is stale');
|
|
289
|
+
const records = this.#database.prepare('SELECT COUNT(*) AS count FROM isolation_jobs').get();
|
|
290
|
+
if (!safeTime(records.count) || records.count >= storage.maxJobRecords)
|
|
291
|
+
fail('unauthorized', 'isolation job record limit reached');
|
|
292
|
+
const occupied = "status IN ('prepared','running') OR (result_json IS NOT NULL AND json_extract(result_json, '$.quiescent') = 0)";
|
|
293
|
+
const legacy = this.#database.prepare(`SELECT COUNT(*) AS count FROM isolation_jobs WHERE (${occupied}) AND reserved_storage_bytes = 0`).get();
|
|
294
|
+
if (!safeTime(legacy.count) || legacy.count > 0)
|
|
295
|
+
fail('unauthorized', 'active legacy job has no storage reservation');
|
|
296
|
+
const reservations = this.#database.prepare(`SELECT COALESCE(SUM(reserved_storage_bytes), 0) AS bytes FROM isolation_jobs WHERE ${occupied}`).get();
|
|
297
|
+
if (!safeTime(reservations.bytes))
|
|
298
|
+
fail('schema');
|
|
299
|
+
const reserved = reservations.bytes + storage.reservedBytes;
|
|
300
|
+
// SQLite reusable pages cannot cover staging growth outside the database.
|
|
301
|
+
// Keep the whole future allocation even when that double-counts reusable space.
|
|
302
|
+
if (!safeTime(reserved) || observation.bytes > storage.maxStateBytes || reserved > storage.maxStateBytes - observation.bytes)
|
|
303
|
+
fail('unauthorized', 'isolation storage budget exhausted');
|
|
304
|
+
}
|
|
305
|
+
const id = randomUUID();
|
|
306
|
+
const deadline = Math.min(now + input.durationMs, current.expires_at);
|
|
307
|
+
this.#database.prepare("INSERT INTO isolation_jobs(id,grant_id,grant_revision,principal_digest,principal_record_id,principal_version,workspace,agent_preset,session_id,idempotency_key,request_digest,container_name,deadline,reserved_duration_ms,reserved_memory_mib,reserved_workspace_inodes,reserved_storage_bytes,dispatch_attempted,status,version,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0, 'prepared',1,?,?)").run(id, input.grantId, current.revision, who.principalDigest, who.principalRecordId, who.principalVersion, who.workspace, who.agentPreset, input.sessionId, input.idempotencyKey, input.requestDigest, `dsh-isolation-${id}`, deadline, input.durationMs, reservation?.memoryMiB ?? 0, reservation?.workspaceInodes ?? 0, storage?.reservedBytes ?? 0, now, now);
|
|
308
|
+
if (binding)
|
|
309
|
+
this.#database.prepare('UPDATE isolation_jobs SET artifact_binding_json=? WHERE id=?').run(JSON.stringify(binding), id);
|
|
310
|
+
this.#audit(now, 'job-prepared', id, input.grantId, `revision:${current.revision}${reservation ? `;reservation:memoryMiB:${reservation.memoryMiB},workspaceInodes:${reservation.workspaceInodes}` : ''}${storage ? `;storageBytes:${storage.reservedBytes}` : ''}`);
|
|
311
|
+
return { job: this.#job(id), created: true };
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/** Commit the crash boundary before a supervisor can receive create authority. */
|
|
315
|
+
markDispatched(jobId, expectedVersion, authority) {
|
|
316
|
+
if (!text(jobId) || !safePositive(expectedVersion))
|
|
317
|
+
fail('invalid-input');
|
|
318
|
+
const controller = this.#authority(authority);
|
|
319
|
+
if (!controller)
|
|
320
|
+
fail('invalid-input');
|
|
321
|
+
return this.#transaction(() => {
|
|
322
|
+
const now = this.#nowValue();
|
|
323
|
+
this.#requireController(controller, now);
|
|
324
|
+
const job = this.#job(jobId);
|
|
325
|
+
if (!job)
|
|
326
|
+
fail('not-found');
|
|
327
|
+
if (job.version !== expectedVersion)
|
|
328
|
+
fail('conflict');
|
|
329
|
+
if (job.status !== 'prepared' || job.dispatchAttempted || !this.usable(jobId))
|
|
330
|
+
fail('invalid-state');
|
|
331
|
+
const changed = this.#database.prepare("UPDATE isolation_jobs SET dispatch_attempted=1, version=version+1, updated_at=? WHERE id=? AND version=? AND status='prepared' AND dispatch_attempted=0").run(now, jobId, expectedVersion);
|
|
332
|
+
if (changed.changes !== 1)
|
|
333
|
+
fail('conflict');
|
|
334
|
+
this.#audit(now, 'supervisor-spawn-intent', jobId, job.grantId, 'create-authority-may-be-dispatched');
|
|
335
|
+
return this.#job(jobId);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
start(jobId, expectedVersion, authority) {
|
|
339
|
+
if (!text(jobId) || !safePositive(expectedVersion))
|
|
340
|
+
fail('invalid-input');
|
|
341
|
+
const controller = this.#authority(authority);
|
|
342
|
+
return this.#transaction(() => {
|
|
343
|
+
const now = this.#nowValue();
|
|
344
|
+
this.#requireController(controller, now);
|
|
345
|
+
const job = this.#job(jobId);
|
|
346
|
+
if (!job)
|
|
347
|
+
fail('not-found');
|
|
348
|
+
if (job.version !== expectedVersion)
|
|
349
|
+
fail('conflict', 'job version differs');
|
|
350
|
+
if (job.status !== 'prepared')
|
|
351
|
+
fail('invalid-state');
|
|
352
|
+
const current = this.#grant(job.grantId);
|
|
353
|
+
if (!current || current.revoked || current.revision !== job.grantRevision || current.expires_at <= now || job.deadline <= now || current.principal_digest !== job.identity.principalDigest || current.principal_record_id !== job.identity.principalRecordId || current.principal_version !== job.identity.principalVersion || current.workspace !== job.identity.workspace || current.agent_preset !== job.identity.agentPreset)
|
|
354
|
+
fail('unauthorized');
|
|
355
|
+
const changed = this.#database.prepare("UPDATE isolation_jobs SET status='running', dispatch_attempted=1, version=version+1, updated_at=? WHERE id=? AND version=? AND status='prepared'").run(now, jobId, expectedVersion);
|
|
356
|
+
if (changed.changes !== 1)
|
|
357
|
+
fail('conflict');
|
|
358
|
+
this.#audit(now, 'job-started', jobId, job.grantId, 'running');
|
|
359
|
+
return this.#job(jobId);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
settle(jobId, expectedVersion, input, authority, creationWitness) {
|
|
363
|
+
if (!text(jobId) || !safePositive(expectedVersion))
|
|
364
|
+
fail('invalid-input');
|
|
365
|
+
const value = result(input);
|
|
366
|
+
if (value.jobId !== jobId || (value.status !== 'unknown' && !value.quiescent))
|
|
367
|
+
fail('invalid-input', 'result job id differs or non-unknown result is not quiescent');
|
|
368
|
+
let witness;
|
|
369
|
+
if (creationWitness !== undefined) {
|
|
370
|
+
try {
|
|
371
|
+
witness = validateCreationWitness(creationWitness);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
fail('invalid-input');
|
|
375
|
+
}
|
|
376
|
+
if (value.status !== 'unknown' || value.quiescent)
|
|
377
|
+
fail('invalid-input');
|
|
378
|
+
}
|
|
379
|
+
if (value.reason === 'docker-creation-unconfirmed' && value.quiescent)
|
|
380
|
+
fail('invalid-input');
|
|
381
|
+
const controller = this.#authority(authority);
|
|
382
|
+
return this.#transaction(() => {
|
|
383
|
+
const now = this.#nowValue();
|
|
384
|
+
this.#requireController(controller, now);
|
|
385
|
+
const job = this.#job(jobId);
|
|
386
|
+
if (!job)
|
|
387
|
+
fail('not-found');
|
|
388
|
+
if (job.version !== expectedVersion)
|
|
389
|
+
fail('conflict', 'job version differs');
|
|
390
|
+
if (witness && !job.dispatchAttempted)
|
|
391
|
+
fail('invalid-state');
|
|
392
|
+
if (job.dispatchAttempted && value.status === 'unknown' && value.quiescent)
|
|
393
|
+
fail('invalid-state', 'dispatch requires independent quiescence evidence');
|
|
394
|
+
if (job.status !== 'prepared' && job.status !== 'running') {
|
|
395
|
+
if (job.result && equal(job.result, value))
|
|
396
|
+
return job;
|
|
397
|
+
const cleanup = !job.dispatchAttempted && job.result?.reason !== 'docker-creation-unconfirmed' && job.status === 'unknown' && job.result?.status === 'unknown' && job.result.quiescent === false && value.status === 'unknown' && value.quiescent === true
|
|
398
|
+
&& equal({ ...job.result, quiescent: true }, value);
|
|
399
|
+
if (!cleanup)
|
|
400
|
+
fail('invalid-state');
|
|
401
|
+
const changed = this.#database.prepare("UPDATE isolation_jobs SET result_json=?, version=version+1, updated_at=? WHERE id=? AND version=? AND status='unknown'").run(JSON.stringify(value), now, jobId, expectedVersion);
|
|
402
|
+
if (changed.changes !== 1)
|
|
403
|
+
fail('conflict');
|
|
404
|
+
this.#audit(now, 'job-quiesced', jobId, job.grantId, 'unknown');
|
|
405
|
+
return this.#job(jobId);
|
|
406
|
+
}
|
|
407
|
+
const changed = this.#database.prepare('UPDATE isolation_jobs SET status=?, result_json=?, creation_witness_json=?, version=version+1, updated_at=? WHERE id=? AND version=? AND status IN (\'prepared\',\'running\')').run(value.status, JSON.stringify(value), witness ? JSON.stringify(witness) : null, now, jobId, expectedVersion);
|
|
408
|
+
if (changed.changes !== 1)
|
|
409
|
+
fail('conflict');
|
|
410
|
+
this.#audit(now, 'job-settled', jobId, job.grantId, value.status);
|
|
411
|
+
return this.#job(jobId);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/** Host-only release after exact cleanup; outcome and origin evidence remain immutable. */
|
|
415
|
+
settleReconciledUnknown(jobId, expectedVersion, proof, authority) {
|
|
416
|
+
if (!text(jobId) || !safePositive(expectedVersion) || !proof || !safeTime(proof.checkedAt))
|
|
417
|
+
fail('invalid-input');
|
|
418
|
+
let original;
|
|
419
|
+
let current;
|
|
420
|
+
try {
|
|
421
|
+
original = validateCreationWitness(proof.original);
|
|
422
|
+
current = validateCreationWitness({ daemon: proof.current, supervisor: proof.current.process,
|
|
423
|
+
...(proof.currentBinding ? { binding: proof.currentBinding } : {}) }).daemon;
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
fail('invalid-input');
|
|
427
|
+
}
|
|
428
|
+
const controller = this.#authority(authority);
|
|
429
|
+
if (!controller)
|
|
430
|
+
fail('invalid-input');
|
|
431
|
+
return this.#transaction(() => {
|
|
432
|
+
const now = this.#nowValue();
|
|
433
|
+
this.#requireController(controller, now);
|
|
434
|
+
if (proof.checkedAt > now || now - proof.checkedAt > 5_000)
|
|
435
|
+
fail('invalid-input', 'cleanup proof expired');
|
|
436
|
+
const job = this.#job(jobId);
|
|
437
|
+
if (!job)
|
|
438
|
+
fail('not-found');
|
|
439
|
+
if (job.version !== expectedVersion || !equal(job.creationWitness, original))
|
|
440
|
+
fail('conflict');
|
|
441
|
+
if (!job.dispatchAttempted || job.status !== 'unknown' || !job.result || job.result.quiescent
|
|
442
|
+
|| original.requestsSettled !== true || !processExited(original.supervisor))
|
|
443
|
+
fail('invalid-state');
|
|
444
|
+
if (original.daemon.engineId !== current.engineId || original.daemon.dockerPath !== current.dockerPath
|
|
445
|
+
|| original.daemon.socketPath !== current.socketPath || original.daemon.pidFile !== current.pidFile)
|
|
446
|
+
fail('invalid-state');
|
|
447
|
+
if (!sameDaemonWitness(original.daemon, current))
|
|
448
|
+
fail('invalid-state', 'daemon generation changed');
|
|
449
|
+
const cleanup = receiptData(proof.cleanup);
|
|
450
|
+
if (!cleanup || cleanup.containerName !== job.containerName || cleanup.dockerPath !== current.dockerPath
|
|
451
|
+
|| cleanup.socketPath !== current.socketPath || cleanup.checkedAt > now || now - cleanup.checkedAt > 5_000)
|
|
452
|
+
fail('invalid-input', 'exact resource cleanup receipt required');
|
|
453
|
+
const mode = 'requests-settled';
|
|
454
|
+
const changed = this.#database.prepare("UPDATE isolation_jobs SET result_json=?, version=version+1, updated_at=? WHERE id=? AND version=? AND status='unknown'")
|
|
455
|
+
.run(JSON.stringify({ ...job.result, quiescent: true }), now, jobId, expectedVersion);
|
|
456
|
+
if (changed.changes !== 1)
|
|
457
|
+
fail('conflict');
|
|
458
|
+
this.#audit(now, 'job-quiesced', jobId, job.grantId, JSON.stringify({ mode, checkedAt: proof.checkedAt, original, current, cleanup,
|
|
459
|
+
...(proof.currentBinding ? { currentBinding: proof.currentBinding } : {}) }));
|
|
460
|
+
return this.#job(jobId);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
revoke(grantId, revision, reason) {
|
|
464
|
+
if (!text(grantId) || !safePositive(revision) || !text(reason))
|
|
465
|
+
fail('invalid-input');
|
|
466
|
+
this.#transaction(() => {
|
|
467
|
+
const current = this.#grant(grantId);
|
|
468
|
+
if (!current)
|
|
469
|
+
fail('not-found');
|
|
470
|
+
if (revision < current.revision)
|
|
471
|
+
fail('conflict', 'grant revision is stale');
|
|
472
|
+
if (revision > current.revision)
|
|
473
|
+
fail('conflict', 'grant revision is unknown');
|
|
474
|
+
if (!current.revoked) {
|
|
475
|
+
const now = this.#nowValue();
|
|
476
|
+
this.#database.prepare('UPDATE isolation_grants SET revoked=1, revoke_reason=? WHERE id=?').run(reason, grantId);
|
|
477
|
+
this.#audit(now, 'grant-revoked', null, grantId, reason);
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
claimController(ownerId, ttlMs) {
|
|
482
|
+
if (!text(ownerId) || !safePositive(ttlMs))
|
|
483
|
+
fail('invalid-input');
|
|
484
|
+
return this.#transaction(() => {
|
|
485
|
+
const now = this.#nowValue();
|
|
486
|
+
const expiresAt = now + ttlMs;
|
|
487
|
+
if (!safeTime(expiresAt))
|
|
488
|
+
fail('invalid-input');
|
|
489
|
+
const current = this.#database.prepare('SELECT owner_id, fence, expires_at FROM isolation_controller WHERE singleton=1').get();
|
|
490
|
+
if (current && (!safePositive(current.fence) || !safeTime(current.expires_at)))
|
|
491
|
+
fail('schema');
|
|
492
|
+
if (current && current.expires_at > now && current.owner_id !== ownerId)
|
|
493
|
+
fail('unauthorized', 'controller is owned by another host');
|
|
494
|
+
const fence = current ? (current.owner_id === ownerId && current.expires_at > now ? current.fence : current.fence + 1) : 1;
|
|
495
|
+
if (!safePositive(fence))
|
|
496
|
+
fail('schema');
|
|
497
|
+
if (current)
|
|
498
|
+
this.#database.prepare('UPDATE isolation_controller SET owner_id=?, fence=?, expires_at=? WHERE singleton=1').run(ownerId, fence, expiresAt);
|
|
499
|
+
else
|
|
500
|
+
this.#database.prepare('INSERT INTO isolation_controller(singleton,owner_id,fence,expires_at) VALUES (1,?,?,?)').run(ownerId, fence, expiresAt);
|
|
501
|
+
this.#audit(now, 'controller-claimed', null, null, `fence:${fence}`);
|
|
502
|
+
return frozen({ ownerId, fence });
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
renewController(authority, ttlMs) {
|
|
506
|
+
const controller = this.#authority(authority);
|
|
507
|
+
if (!controller || !safePositive(ttlMs))
|
|
508
|
+
fail('invalid-input');
|
|
509
|
+
return this.#transaction(() => { const now = this.#nowValue(); const expiresAt = now + ttlMs; if (!safeTime(expiresAt))
|
|
510
|
+
fail('invalid-input'); const changed = this.#database.prepare('UPDATE isolation_controller SET expires_at=? WHERE singleton=1 AND owner_id=? AND fence=? AND expires_at>?').run(expiresAt, controller.ownerId, controller.fence, now); return changed.changes === 1; });
|
|
511
|
+
}
|
|
512
|
+
releaseController(authority) {
|
|
513
|
+
const controller = this.#authority(authority);
|
|
514
|
+
if (!controller)
|
|
515
|
+
fail('invalid-input');
|
|
516
|
+
this.#transaction(() => { const now = this.#nowValue(); const changed = this.#database.prepare('UPDATE isolation_controller SET expires_at=? WHERE singleton=1 AND owner_id=? AND fence=? AND expires_at>?').run(now, controller.ownerId, controller.fence, now); if (changed.changes === 1)
|
|
517
|
+
this.#audit(now, 'controller-released', null, null, `fence:${controller.fence}`); });
|
|
518
|
+
}
|
|
519
|
+
hasController(authority) { const controller = this.#authority(authority); if (!controller)
|
|
520
|
+
return false; const row = this.#database.prepare('SELECT owner_id, fence, expires_at FROM isolation_controller WHERE singleton=1').get(); return !!row && row.owner_id === controller.ownerId && row.fence === controller.fence && safeTime(row.expires_at) && row.expires_at > this.#nowValue(); }
|
|
521
|
+
get(jobId) { if (!text(jobId))
|
|
522
|
+
fail('invalid-input'); return this.#job(jobId); }
|
|
523
|
+
recoverable(afterId = '') { return this.#database.prepare("SELECT * FROM isolation_jobs WHERE id > ? AND (status IN ('prepared','running') OR (result_json IS NOT NULL AND json_extract(result_json, '$.quiescent') = 0)) ORDER BY id ASC LIMIT 1000").all(afterId).map(row => this.#decodeJob(row)).filter(job => job.status === 'prepared' || job.status === 'running' || job.result?.quiescent === false); }
|
|
524
|
+
retentionCandidates(cutoff, afterId = '', limit = 16) {
|
|
525
|
+
if (!safeTime(cutoff) || typeof afterId !== 'string' || afterId.length > textMaximum || !safePositive(limit) || limit > 1_000)
|
|
526
|
+
fail('invalid-input');
|
|
527
|
+
const rows = this.#database.prepare("SELECT id, version FROM isolation_jobs WHERE id > ? AND updated_at <= ? AND status IN ('succeeded','failed','cancelled','timed-out') AND result_json IS NOT NULL AND json_extract(result_json, '$.quiescent') = 1 AND json_extract(result_json, '$.retention') IS NULL ORDER BY id ASC LIMIT ?").all(afterId, cutoff, limit);
|
|
528
|
+
if (!rows.every(row => text(row.id) && safePositive(row.version)))
|
|
529
|
+
fail('schema');
|
|
530
|
+
return rows.map(row => frozen({ id: row.id, version: row.version }));
|
|
531
|
+
}
|
|
532
|
+
compactResult(id, expectedVersion, cutoff, authority) {
|
|
533
|
+
if (!text(id) || !safePositive(expectedVersion) || !safeTime(cutoff))
|
|
534
|
+
fail('invalid-input');
|
|
535
|
+
const controller = this.#authority(authority);
|
|
536
|
+
if (!controller)
|
|
537
|
+
fail('invalid-input');
|
|
538
|
+
return this.#transaction(() => {
|
|
539
|
+
const now = this.#nowValue();
|
|
540
|
+
this.#requireController(controller, now);
|
|
541
|
+
const job = this.#job(id);
|
|
542
|
+
if (!job)
|
|
543
|
+
fail('not-found');
|
|
544
|
+
if (job.version !== expectedVersion)
|
|
545
|
+
fail('conflict');
|
|
546
|
+
if (job.updatedAt > cutoff || !['succeeded', 'failed', 'cancelled', 'timed-out'].includes(job.status) || !job.result || !job.result.quiescent || job.result.retention)
|
|
547
|
+
return { changed: false, removedBytes: 0 };
|
|
548
|
+
// Candidate eligibility is based on the job timestamp; pruning itself is deterministic.
|
|
549
|
+
const compacted = pruneResult(job.result, now);
|
|
550
|
+
if (!compacted)
|
|
551
|
+
return { changed: false, removedBytes: 0 };
|
|
552
|
+
const before = Buffer.byteLength(JSON.stringify(job.result), 'utf8');
|
|
553
|
+
const after = Buffer.byteLength(JSON.stringify(compacted), 'utf8');
|
|
554
|
+
const removedBytes = Math.max(0, before - after);
|
|
555
|
+
const changed = this.#database.prepare("UPDATE isolation_jobs SET result_json=?, version=version+1, updated_at=? WHERE id=? AND version=? AND status IN ('succeeded','failed','cancelled','timed-out')").run(JSON.stringify(compacted), now, id, expectedVersion);
|
|
556
|
+
if (changed.changes !== 1)
|
|
557
|
+
fail('conflict');
|
|
558
|
+
this.#audit(now, 'job-result-pruned', id, job.grantId, JSON.stringify({ original: compacted.retention.original, prunedAt: compacted.retention.prunedAt, version: expectedVersion }));
|
|
559
|
+
return { changed: true, removedBytes };
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
storageStats() {
|
|
563
|
+
const counts = this.#database.prepare("SELECT COUNT(*) AS jobRecords, COALESCE(SUM(CASE WHEN status IN ('prepared','running') OR (result_json IS NOT NULL AND json_extract(result_json, '$.quiescent') = 0) THEN reserved_storage_bytes ELSE 0 END), 0) AS activeReservedBytes, COUNT(CASE WHEN (status IN ('prepared','running') OR (result_json IS NOT NULL AND json_extract(result_json, '$.quiescent') = 0)) AND reserved_storage_bytes = 0 THEN 1 END) AS legacyActiveJobs FROM isolation_jobs").get();
|
|
564
|
+
const pages = this.#database.prepare('PRAGMA page_size').get();
|
|
565
|
+
const free = this.#database.prepare('PRAGMA freelist_count').get();
|
|
566
|
+
if (!safeTime(counts.jobRecords) || !safeTime(counts.activeReservedBytes) || !safeTime(counts.legacyActiveJobs) || !safePositive(pages.page_size) || !safeTime(free.freelist_count) || free.freelist_count > Math.floor(Number.MAX_SAFE_INTEGER / pages.page_size))
|
|
567
|
+
fail('schema');
|
|
568
|
+
return frozen({ jobRecords: counts.jobRecords, reusableBytes: free.freelist_count * pages.page_size, activeReservedBytes: counts.activeReservedBytes, legacyActiveJobs: counts.legacyActiveJobs });
|
|
569
|
+
}
|
|
570
|
+
maintainStorage(authority) {
|
|
571
|
+
const controller = this.#authority(authority);
|
|
572
|
+
if (!controller)
|
|
573
|
+
fail('invalid-input');
|
|
574
|
+
const now = this.#nowValue();
|
|
575
|
+
this.#requireController(controller, now);
|
|
576
|
+
const auto = this.#database.prepare('PRAGMA auto_vacuum').get();
|
|
577
|
+
if (auto.auto_vacuum !== 0 && auto.auto_vacuum !== 2)
|
|
578
|
+
fail('schema');
|
|
579
|
+
let checkpoint = 'complete';
|
|
580
|
+
try {
|
|
581
|
+
this.#database.exec('PRAGMA busy_timeout = 0');
|
|
582
|
+
const initial = this.#database.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get();
|
|
583
|
+
if (!initial || initial.busy !== 0)
|
|
584
|
+
checkpoint = 'busy';
|
|
585
|
+
else if (auto.auto_vacuum === 2) {
|
|
586
|
+
this.#database.exec('PRAGMA incremental_vacuum(64)');
|
|
587
|
+
const final = this.#database.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get();
|
|
588
|
+
if (!final || final.busy !== 0)
|
|
589
|
+
checkpoint = 'busy';
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
this.#database.exec('PRAGMA busy_timeout = 5000');
|
|
594
|
+
}
|
|
595
|
+
this.#requireController(controller, this.#nowValue());
|
|
596
|
+
return frozen({ checkpoint, reclaimMode: auto.auto_vacuum === 2 ? 'incremental' : 'page-reuse' });
|
|
597
|
+
}
|
|
598
|
+
/** Newer declared output attempts mask older success, including failed/unknown attempts. */
|
|
599
|
+
acceptedArtifactJob(contractId, contractDigest, path) {
|
|
600
|
+
if (!text(contractId) || !/^[0-9a-f]{64}$/.test(contractDigest) || !text(path, 4096))
|
|
601
|
+
fail('invalid-input');
|
|
602
|
+
const row = this.#database.prepare("SELECT * FROM isolation_jobs WHERE artifact_binding_json IS NOT NULL AND json_extract(artifact_binding_json, '$.admission.contractId')=? AND EXISTS (SELECT 1 FROM json_each(artifact_binding_json, '$.paths') WHERE value=?) ORDER BY rowid DESC LIMIT 1").get(contractId, path);
|
|
603
|
+
if (!row)
|
|
604
|
+
return undefined;
|
|
605
|
+
const job = this.#decodeJob(row);
|
|
606
|
+
if (job.artifactBinding?.admission.contractDigest !== contractDigest)
|
|
607
|
+
fail('conflict', 'artifact contract digest differs');
|
|
608
|
+
return job;
|
|
609
|
+
}
|
|
610
|
+
/** Read-only current grant check for the exact native tool preauthorization. */
|
|
611
|
+
permitsGrant(value, grantId) {
|
|
612
|
+
const requested = identity(value);
|
|
613
|
+
const current = this.#grant(grantId);
|
|
614
|
+
return !!current && current.revoked === 0 && current.expires_at > this.#nowValue()
|
|
615
|
+
&& current.principal_digest === requested.principalDigest && current.principal_record_id === requested.principalRecordId
|
|
616
|
+
&& current.principal_version === requested.principalVersion && current.workspace === requested.workspace
|
|
617
|
+
&& current.agent_preset === requested.agentPreset;
|
|
618
|
+
}
|
|
619
|
+
usable(jobId) { const job = this.#job(jobId); if (!job || !['prepared', 'running'].includes(job.status) || job.deadline <= this.#nowValue())
|
|
620
|
+
return false; const current = this.#grant(job.grantId); return !!current && !current.revoked && current.revision === job.grantRevision && current.expires_at > this.#nowValue() && current.principal_digest === job.identity.principalDigest && current.principal_record_id === job.identity.principalRecordId && current.principal_version === job.identity.principalVersion && current.workspace === job.identity.workspace && current.agent_preset === job.identity.agentPreset; }
|
|
621
|
+
close() { this.#database.close(); }
|
|
622
|
+
}
|
|
623
|
+
//# sourceMappingURL=ledger.js.map
|