@ouro.bot/cli 0.1.0-alpha.934 → 0.1.0-alpha.936
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.json +18 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/cli-defaults.js +5 -0
- package/dist/heart/daemon/cli-exec.js +9 -0
- package/dist/heart/daemon/cli-help.js +2 -2
- package/dist/heart/daemon/cli-parse.js +2 -1
- package/dist/heart/daemon/daemon.js +14 -0
- package/dist/heart/daemon/ouro-entry.js +4 -1
- package/dist/heart/daemon/runtime-logging.js +1 -1
- package/dist/heart/daemon/sense-manager.js +53 -0
- package/dist/heart/mailbox/mailbox-http-hooks.js +1 -0
- package/dist/heart/mailbox/mailbox-http-routes.js +8 -0
- package/dist/heart/mailbox/mailbox-read.js +3 -1
- package/dist/heart/mailbox/mailbox-supervision.js +131 -0
- package/dist/heart/shepherd/custody-types.js +2 -0
- package/dist/heart/shepherd/mandates.js +352 -0
- package/dist/heart/shepherd/operation-commands.js +19 -0
- package/dist/heart/shepherd/operations.js +135 -0
- package/dist/heart/shepherd/supervision.js +114 -0
- package/dist/heart/shepherd/types.js +2 -0
- package/dist/mailbox-ui/assets/index-DsiKaPyH.css +1 -0
- package/dist/mailbox-ui/assets/index-xU5krmwW.js +1 -0
- package/dist/mailbox-ui/index.html +2 -2
- package/dist/nerves/coverage/file-completeness.js +4 -0
- package/dist/repertoire/tools-shell.js +9 -4
- package/dist/senses/shepherd/checkin-store.js +209 -41
- package/dist/senses/shepherd/cmux.js +69 -23
- package/dist/senses/shepherd/guidance.js +5 -2
- package/dist/senses/shepherd/herdr.js +84 -2
- package/dist/senses/shepherd/observation-publisher.js +320 -0
- package/dist/senses/shepherd/operations-defaults.js +86 -0
- package/dist/senses/shepherd/operations.js +159 -0
- package/dist/senses/shepherd/recovery.js +543 -0
- package/dist/senses/shepherd/sense.js +8 -0
- package/dist/senses/teams.js +13 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/mailbox-ui/assets/index-Cyv4mw3Y.js +0 -1
- package/dist/mailbox-ui/assets/index-Du_9G9WO.css +0 -1
|
@@ -36,7 +36,20 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.refuseCustody = refuseCustody;
|
|
40
|
+
exports.validCustodyInputReceipt = validCustodyInputReceipt;
|
|
41
|
+
exports.copyCustodyInputReceipt = copyCustodyInputReceipt;
|
|
42
|
+
exports.parseCustodyDispatch = parseCustodyDispatch;
|
|
43
|
+
exports.validateCustodyDirectory = validateCustodyDirectory;
|
|
39
44
|
exports.checkInStorePath = checkInStorePath;
|
|
45
|
+
exports.assertNoCustodyMaintenance = assertNoCustodyMaintenance;
|
|
46
|
+
exports.custodyCapacity = custodyCapacity;
|
|
47
|
+
exports.custodyRevision = custodyRevision;
|
|
48
|
+
exports.advanceCustodyRevision = advanceCustodyRevision;
|
|
49
|
+
exports.hasRetiredCustodyInstance = hasRetiredCustodyInstance;
|
|
50
|
+
exports.retiredCustodyKey = retiredCustodyKey;
|
|
51
|
+
exports.readRetiredCustodyRecord = readRetiredCustodyRecord;
|
|
52
|
+
exports.openCustodyDatabase = openCustodyDatabase;
|
|
40
53
|
exports.openCheckInStore = openCheckInStore;
|
|
41
54
|
const node_crypto_1 = require("node:crypto");
|
|
42
55
|
const fs = __importStar(require("node:fs"));
|
|
@@ -45,17 +58,20 @@ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
|
45
58
|
const runtime_1 = require("../../nerves/runtime");
|
|
46
59
|
const returns_1 = require("./returns");
|
|
47
60
|
const MAX_ROW = 4096;
|
|
61
|
+
const COMPLETION_HEADROOM = 2300;
|
|
48
62
|
const MAX_DB = 64 * 1024 * 1024;
|
|
49
63
|
const WARN_DB = 48 * 1024 * 1024;
|
|
64
|
+
const warnedDatabases = new WeakSet();
|
|
50
65
|
const bytes = (value) => Buffer.byteLength(JSON.stringify(value));
|
|
51
66
|
const text = (value) => typeof value === "string" && value.length > 0;
|
|
52
67
|
const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
53
68
|
const timestamp = (value) => text(value) && Number.isFinite(Date.parse(value));
|
|
54
|
-
function
|
|
69
|
+
function refuseCustody(reason) {
|
|
55
70
|
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_checkin_store_refused",
|
|
56
71
|
message: "check-in custody operation refused", meta: { reason } });
|
|
57
72
|
throw new Error(`Shepherd check-in custody: ${reason}`);
|
|
58
73
|
}
|
|
74
|
+
const refuse = refuseCustody;
|
|
59
75
|
function bounded(value, limit, field) {
|
|
60
76
|
if (!text(value) || bytes(value) > limit)
|
|
61
77
|
refuse(`${field} byte limit or type`);
|
|
@@ -72,9 +88,24 @@ function dispatchId(input) {
|
|
|
72
88
|
function validClaim(value) {
|
|
73
89
|
return object(value) && validInput(value) && text(value.owner) && bytes(value.owner) <= 256 &&
|
|
74
90
|
["claimed", "sent", "not_sent", "unknown"].includes(String(value.state)) &&
|
|
75
|
-
(value.reason === undefined || text(value.reason) && bytes(value.reason) <= 1024)
|
|
91
|
+
(value.reason === undefined || text(value.reason) && bytes(value.reason) <= 1024)
|
|
92
|
+
&& (value.receipt === undefined || validCustodyInputReceipt(value.receipt)
|
|
93
|
+
&& value.receipt.requestId === value.id && value.receipt.surfaceId === value.session && value.receipt.processInstance === value.instance
|
|
94
|
+
&& receiptMatchesOutcome(value.state, value.receipt));
|
|
76
95
|
}
|
|
77
|
-
function
|
|
96
|
+
function validCustodyInputReceipt(value) {
|
|
97
|
+
return object(value) && ["requestId", "surfaceId", "workspaceId", "processInstance"].every(key => text(value[key]) && bytes(value[key]) <= 256)
|
|
98
|
+
&& typeof value.pasteAdmitted === "boolean" && typeof value.submitAdmitted === "boolean"
|
|
99
|
+
&& (!value.submitAdmitted || value.pasteAdmitted);
|
|
100
|
+
}
|
|
101
|
+
function copyCustodyInputReceipt(receipt) {
|
|
102
|
+
const { requestId, surfaceId, workspaceId, processInstance, pasteAdmitted, submitAdmitted } = receipt;
|
|
103
|
+
return { requestId, surfaceId, workspaceId, processInstance, pasteAdmitted, submitAdmitted };
|
|
104
|
+
}
|
|
105
|
+
function receiptMatchesOutcome(state, receipt) {
|
|
106
|
+
return state === "sent" ? receipt.pasteAdmitted && receipt.submitAdmitted : state !== "not_sent" || !receipt.pasteAdmitted && !receipt.submitAdmitted;
|
|
107
|
+
}
|
|
108
|
+
function parseCustodyDispatch(row) {
|
|
78
109
|
if (Buffer.byteLength(row.data) > MAX_ROW)
|
|
79
110
|
refuse("record byte limit");
|
|
80
111
|
const value = JSON.parse(row.data);
|
|
@@ -83,71 +114,196 @@ function parseDispatch(row) {
|
|
|
83
114
|
refuse("invalid dispatch schema");
|
|
84
115
|
return value;
|
|
85
116
|
}
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
const parseDispatch = parseCustodyDispatch;
|
|
118
|
+
function validateCustodyDirectory(directory, create) {
|
|
119
|
+
const uid = process.getuid();
|
|
120
|
+
for (let candidate = directory; candidate !== path.dirname(candidate); candidate = path.dirname(candidate)) {
|
|
121
|
+
const stat = fs.lstatSync(candidate, { throwIfNoEntry: false });
|
|
122
|
+
if (stat?.isSymbolicLink())
|
|
123
|
+
refuse("private directory ancestor is a link");
|
|
124
|
+
if (stat && (!stat.isDirectory() || stat.uid !== 0 && stat.uid !== uid
|
|
125
|
+
|| (stat.mode & 0o022) !== 0 && !(stat.uid === 0 && (stat.mode & 0o1000) !== 0)))
|
|
126
|
+
refuse("private directory ancestor ownership or mode");
|
|
127
|
+
}
|
|
128
|
+
if (create)
|
|
129
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
130
|
+
if (!fs.lstatSync(directory, { throwIfNoEntry: false }))
|
|
131
|
+
return false;
|
|
88
132
|
const stat = fs.lstatSync(directory);
|
|
89
|
-
if (!stat.isDirectory() || (stat.mode &
|
|
90
|
-
refuse("private directory ownership or mode");
|
|
133
|
+
if (!stat.isDirectory() || (stat.mode & 0o7777) !== 0o700 || stat.uid !== uid)
|
|
134
|
+
refuse("private directory ownership or mode; require 0700");
|
|
135
|
+
return true;
|
|
91
136
|
}
|
|
92
137
|
function privateFile(file) {
|
|
93
138
|
const stat = fs.lstatSync(file);
|
|
94
|
-
if (!stat.isFile() || stat.nlink !== 1 || (stat.mode &
|
|
139
|
+
if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o7777) !== 0o600 || stat.uid !== process.getuid())
|
|
95
140
|
refuse("private file ownership or mode");
|
|
96
141
|
}
|
|
97
142
|
function checkInStorePath(agentRoot) {
|
|
98
143
|
return path.join((0, returns_1.shepherdStateDir)(agentRoot), "checkins", "custody.sqlite");
|
|
99
144
|
}
|
|
100
|
-
function
|
|
145
|
+
function assertNoCustodyMaintenance(file) {
|
|
146
|
+
if (fs.lstatSync(`${file}.maintenance.json`, { throwIfNoEntry: false }))
|
|
147
|
+
refuse("interrupted or active maintenance; resume the exact owner maintenance request");
|
|
148
|
+
}
|
|
149
|
+
function custodyCapacity(database, writing = false) {
|
|
150
|
+
const size = Number(database.pragma("page_count", { simple: true })) * Number(database.pragma("page_size", { simple: true }));
|
|
151
|
+
if (size > MAX_DB || writing && size + 65536 > MAX_DB)
|
|
152
|
+
refuse("database capacity; no input authorized");
|
|
153
|
+
if (!warnedDatabases.has(database) && size >= WARN_DB) {
|
|
154
|
+
warnedDatabases.add(database);
|
|
155
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_checkin_capacity_warning",
|
|
156
|
+
message: "check-in custody is approaching capacity", meta: { bytes: size, limit: MAX_DB } });
|
|
157
|
+
}
|
|
158
|
+
return { bytes: size, capacity: size + 65536 > MAX_DB ? "refused" : size >= WARN_DB ? "warning" : "normal" };
|
|
159
|
+
}
|
|
160
|
+
function custodyRevision(database) {
|
|
161
|
+
const row = database.prepare("SELECT revision FROM custody_meta WHERE singleton=1").get();
|
|
162
|
+
if (!row || typeof row.revision !== "number" || !Number.isSafeInteger(row.revision) || row.revision < 0)
|
|
163
|
+
refuse("invalid custody revision schema");
|
|
164
|
+
return row.revision;
|
|
165
|
+
}
|
|
166
|
+
function advanceCustodyRevision(database) {
|
|
167
|
+
const revision = custodyRevision(database) + 1;
|
|
168
|
+
if (!Number.isSafeInteger(revision))
|
|
169
|
+
refuse("custody revision capacity");
|
|
170
|
+
database.prepare("UPDATE custody_meta SET revision=? WHERE singleton=1").run(revision);
|
|
171
|
+
return revision;
|
|
172
|
+
}
|
|
173
|
+
function hasRetiredCustodyInstance(database, input) {
|
|
174
|
+
const row = database.prepare(`SELECT key,host,session,instance,data FROM retired_instances WHERE host=? AND session=?
|
|
175
|
+
AND (instance IS NULL OR ? IS NULL OR instance=?) LIMIT 1`).get(input.host, input.session, input.instance, input.instance);
|
|
176
|
+
if (!row)
|
|
177
|
+
return false;
|
|
178
|
+
validateRetiredRow(row);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
function retiredCustodyKey(input) {
|
|
182
|
+
return (0, node_crypto_1.createHash)("sha256").update(JSON.stringify([input.host, input.session, input.instance])).digest("hex");
|
|
183
|
+
}
|
|
184
|
+
function readRetiredCustodyRecord(database, input) {
|
|
185
|
+
const row = database.prepare("SELECT key,host,session,instance,data FROM retired_instances WHERE key=?").get(retiredCustodyKey(input));
|
|
186
|
+
return row ? validateRetiredRow(row) : null;
|
|
187
|
+
}
|
|
188
|
+
function validateRetiredRow(row) {
|
|
189
|
+
if (Buffer.byteLength(row.data) > MAX_ROW)
|
|
190
|
+
refuse("retired instance index capacity");
|
|
191
|
+
const data = JSON.parse(row.data);
|
|
192
|
+
const expectedKey = retiredCustodyKey(row);
|
|
193
|
+
if (!object(data) || !object(data.proof) || row.key !== expectedKey || data.proof.kind !== "host_process_inventory"
|
|
194
|
+
|| data.proof.host !== row.host || data.proof.session !== row.session || data.proof.instance !== row.instance
|
|
195
|
+
|| data.proof.gone !== true || !timestamp(data.proof.checkedAt) || typeof data.proof.wholeSessionRetired !== "boolean"
|
|
196
|
+
|| row.instance === null && !data.proof.wholeSessionRetired)
|
|
197
|
+
refuse("invalid retired instance schema");
|
|
198
|
+
if (data.archive !== undefined && (!object(data.archive) || typeof data.archive.path !== "string" || !path.isAbsolute(data.archive.path)
|
|
199
|
+
|| typeof data.archive.digest !== "string" || !/^[a-f0-9]{64}$/.test(data.archive.digest)))
|
|
200
|
+
refuse("invalid retired archive reference schema");
|
|
201
|
+
return data;
|
|
202
|
+
}
|
|
203
|
+
function openCustodyDatabase(file, mode) {
|
|
204
|
+
const readonly = mode === "read" || mode === "maintenance-read";
|
|
101
205
|
if (!path.isAbsolute(file) || path.normalize(file) !== file)
|
|
102
206
|
refuse("database path");
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
throw error;
|
|
207
|
+
if (mode !== "maintenance" && mode !== "maintenance-read")
|
|
208
|
+
assertNoCustodyMaintenance(file);
|
|
209
|
+
if (!validateCustodyDirectory(path.dirname(file), !readonly))
|
|
210
|
+
return null;
|
|
211
|
+
if (readonly) {
|
|
212
|
+
if (!fs.lstatSync(file, { throwIfNoEntry: false }))
|
|
213
|
+
return null;
|
|
111
214
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
warned = true;
|
|
121
|
-
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_checkin_capacity_warning",
|
|
122
|
-
message: "check-in custody is approaching capacity", meta: { bytes: size, limit: MAX_DB } });
|
|
215
|
+
else {
|
|
216
|
+
try {
|
|
217
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
218
|
+
fs.closeSync(fd);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (error.code !== "EEXIST")
|
|
222
|
+
throw error;
|
|
123
223
|
}
|
|
124
224
|
}
|
|
225
|
+
privateFile(file);
|
|
226
|
+
for (const suffix of ["-journal", "-wal", "-shm"])
|
|
227
|
+
if (fs.lstatSync(`${file}${suffix}`, { throwIfNoEntry: false }))
|
|
228
|
+
privateFile(`${file}${suffix}`);
|
|
229
|
+
const database = new better_sqlite3_1.default(file, { fileMustExist: true, readonly });
|
|
125
230
|
try {
|
|
126
231
|
const version = database.pragma("user_version", { simple: true });
|
|
127
|
-
if (version
|
|
232
|
+
if (![0, 1, 2].includes(Number(version)) || version === 0 && readonly)
|
|
128
233
|
refuse("unsupported database schema");
|
|
129
|
-
|
|
130
|
-
database.pragma("journal_mode = DELETE");
|
|
234
|
+
custodyCapacity(database);
|
|
131
235
|
database.pragma("busy_timeout = 1000");
|
|
236
|
+
if (version !== 0) {
|
|
237
|
+
database.prepare("SELECT id,host,session,instance,data FROM dispatches LIMIT 0").all();
|
|
238
|
+
database.prepare("SELECT signature,data FROM notices LIMIT 0").all();
|
|
239
|
+
}
|
|
240
|
+
if (readonly) {
|
|
241
|
+
if (version === 2)
|
|
242
|
+
validateCustodyV2(database);
|
|
243
|
+
return database;
|
|
244
|
+
}
|
|
245
|
+
database.pragma("journal_mode = DELETE");
|
|
246
|
+
database.pragma("synchronous = FULL");
|
|
132
247
|
database.pragma(`max_page_count = ${Math.floor(MAX_DB / Number(database.pragma("page_size", { simple: true })))}`);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
248
|
+
if (version !== 2)
|
|
249
|
+
database.transaction(() => {
|
|
250
|
+
if (database.pragma("user_version", { simple: true }) === 2)
|
|
251
|
+
return;
|
|
252
|
+
database.exec(`CREATE TABLE IF NOT EXISTS dispatches (
|
|
253
|
+
id TEXT PRIMARY KEY NOT NULL, host TEXT NOT NULL, session TEXT NOT NULL, instance TEXT, data TEXT NOT NULL
|
|
254
|
+
);
|
|
255
|
+
CREATE INDEX IF NOT EXISTS dispatch_barriers ON dispatches(host,session,instance);
|
|
256
|
+
CREATE TABLE IF NOT EXISTS notices (signature TEXT PRIMARY KEY NOT NULL, data TEXT NOT NULL);
|
|
257
|
+
CREATE TABLE custody_meta (singleton INTEGER PRIMARY KEY CHECK(singleton=1), revision INTEGER NOT NULL);
|
|
258
|
+
INSERT INTO custody_meta VALUES(1,0);
|
|
259
|
+
CREATE TABLE custody_requests (actor_id TEXT NOT NULL,request_id TEXT NOT NULL,hash TEXT NOT NULL,receipt TEXT NOT NULL,PRIMARY KEY(actor_id,request_id));
|
|
260
|
+
CREATE TABLE reconciliations (id TEXT PRIMARY KEY,dispatch_id TEXT NOT NULL,data TEXT NOT NULL);
|
|
261
|
+
CREATE INDEX reconciliation_dispatch ON reconciliations(dispatch_id);
|
|
262
|
+
CREATE TABLE retired_instances (key TEXT PRIMARY KEY,host TEXT NOT NULL,session TEXT NOT NULL,instance TEXT,data TEXT NOT NULL);
|
|
263
|
+
CREATE INDEX retired_scope ON retired_instances(host,session,instance);
|
|
264
|
+
CREATE TABLE custody_migrations (version INTEGER PRIMARY KEY,data TEXT NOT NULL);`);
|
|
265
|
+
for (const row of database.prepare("SELECT id,host,session,instance,data FROM dispatches").iterate())
|
|
266
|
+
parseDispatch(row);
|
|
267
|
+
const counts = database.prepare("SELECT COUNT(*) AS count FROM dispatches").get();
|
|
268
|
+
database.prepare("INSERT INTO custody_migrations VALUES(2,?)").run(JSON.stringify({ fromVersion: version, at: new Date().toISOString(), preservedDispatches: counts.count }));
|
|
269
|
+
database.pragma("user_version = 2");
|
|
270
|
+
}).immediate();
|
|
271
|
+
validateCustodyV2(database);
|
|
272
|
+
for (const row of database.prepare("SELECT id,host,session,instance,data FROM dispatches").iterate())
|
|
273
|
+
parseDispatch(row);
|
|
139
274
|
}
|
|
140
275
|
catch (error) {
|
|
141
276
|
database.close();
|
|
142
277
|
throw error;
|
|
143
278
|
}
|
|
144
|
-
|
|
279
|
+
return database;
|
|
280
|
+
}
|
|
281
|
+
function validateCustodyV2(database) {
|
|
282
|
+
custodyRevision(database);
|
|
283
|
+
for (const query of [
|
|
284
|
+
"SELECT actor_id,request_id,hash,receipt FROM custody_requests LIMIT 0",
|
|
285
|
+
"SELECT id,dispatch_id,data FROM reconciliations LIMIT 0",
|
|
286
|
+
"SELECT key,host,session,instance,data FROM retired_instances LIMIT 0",
|
|
287
|
+
"SELECT version,data FROM custody_migrations LIMIT 0",
|
|
288
|
+
])
|
|
289
|
+
database.prepare(query).all();
|
|
290
|
+
for (const row of database.prepare("SELECT key,host,session,instance,data FROM retired_instances").iterate())
|
|
291
|
+
validateRetiredRow(row);
|
|
292
|
+
}
|
|
293
|
+
function openCheckInStore(file) {
|
|
294
|
+
const database = openCustodyDatabase(file, "write");
|
|
295
|
+
const transact = (work) => database.transaction(() => {
|
|
296
|
+
assertNoCustodyMaintenance(file);
|
|
297
|
+
custodyCapacity(database, true);
|
|
298
|
+
return work();
|
|
299
|
+
}).immediate();
|
|
145
300
|
function write(record) {
|
|
146
301
|
const data = JSON.stringify(record);
|
|
147
302
|
if (Buffer.byteLength(data) > MAX_ROW)
|
|
148
303
|
refuse("record byte limit");
|
|
149
304
|
database.prepare(`INSERT INTO dispatches(id,host,session,instance,data) VALUES(?,?,?,?,?)
|
|
150
305
|
ON CONFLICT(id) DO UPDATE SET data=excluded.data`).run(record.id, record.host, record.session, record.instance, data);
|
|
306
|
+
advanceCustodyRevision(database);
|
|
151
307
|
}
|
|
152
308
|
function notice(signature) {
|
|
153
309
|
bounded(signature, MAX_ROW - 128, "signature");
|
|
@@ -169,9 +325,11 @@ function openCheckInStore(file) {
|
|
|
169
325
|
refuse("invalid dispatch input schema");
|
|
170
326
|
bounded(owner, 256, "owner");
|
|
171
327
|
const record = { ...input, id: dispatchId(input), owner, state: "claimed" };
|
|
172
|
-
if (bytes(record) +
|
|
328
|
+
if (bytes(record) + COMPLETION_HEADROOM > MAX_ROW)
|
|
173
329
|
refuse("reserved record byte limit");
|
|
174
330
|
return transact(() => {
|
|
331
|
+
if (hasRetiredCustodyInstance(database, input))
|
|
332
|
+
return null;
|
|
175
333
|
const rows = database.prepare(`SELECT id,host,session,instance,data FROM dispatches
|
|
176
334
|
WHERE id=? OR (host=? AND session=? AND (instance IS NULL OR ? IS NULL OR instance=?))`)
|
|
177
335
|
.all(record.id, input.host, input.session, input.instance, input.instance);
|
|
@@ -190,6 +348,10 @@ function openCheckInStore(file) {
|
|
|
190
348
|
refuse("invalid completion state");
|
|
191
349
|
if (result.reason !== undefined)
|
|
192
350
|
bounded(result.reason, 1024, "reason");
|
|
351
|
+
if (result.receipt !== undefined && !validCustodyInputReceipt(result.receipt))
|
|
352
|
+
refuse("invalid host receipt schema");
|
|
353
|
+
if (result.receipt && !receiptMatchesOutcome(result.state, result.receipt))
|
|
354
|
+
refuse("host receipt contradicts completion outcome");
|
|
193
355
|
transact(() => {
|
|
194
356
|
const row = database.prepare("SELECT id,host,session,instance,data FROM dispatches WHERE id=?").get(id);
|
|
195
357
|
if (!row)
|
|
@@ -197,7 +359,11 @@ function openCheckInStore(file) {
|
|
|
197
359
|
const prior = parseDispatch(row);
|
|
198
360
|
if (prior.owner !== owner || prior.state !== "claimed")
|
|
199
361
|
refuse("dispatch owner or phase mismatch");
|
|
200
|
-
|
|
362
|
+
if (result.receipt && (result.receipt.requestId !== id || result.receipt.surfaceId !== prior.session
|
|
363
|
+
|| result.receipt.processInstance !== prior.instance))
|
|
364
|
+
refuse("host receipt identity mismatch");
|
|
365
|
+
write({ ...prior, state: result.state, ...(result.reason === undefined ? {} : { reason: result.reason }),
|
|
366
|
+
...(result.receipt === undefined ? {} : { receipt: copyCustodyInputReceipt(result.receipt) }) });
|
|
201
367
|
});
|
|
202
368
|
},
|
|
203
369
|
shouldNotify(signature) { return notice(signature) === null; },
|
|
@@ -209,8 +375,10 @@ function openCheckInStore(file) {
|
|
|
209
375
|
if (Buffer.byteLength(data) > MAX_ROW)
|
|
210
376
|
refuse("notice byte limit");
|
|
211
377
|
transact(() => {
|
|
212
|
-
if (!notice(signature))
|
|
378
|
+
if (!notice(signature)) {
|
|
213
379
|
database.prepare("INSERT INTO notices(signature,data) VALUES(?,?)").run(signature, data);
|
|
380
|
+
advanceCustodyRevision(database);
|
|
381
|
+
}
|
|
214
382
|
});
|
|
215
383
|
},
|
|
216
384
|
close() { database.close(); },
|
|
@@ -132,31 +132,46 @@ function createCmuxHost(client, options = {}) {
|
|
|
132
132
|
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_cmux_status_error", message: "could not clear a Shepherd status", meta: { error: error.message } });
|
|
133
133
|
});
|
|
134
134
|
};
|
|
135
|
-
async function surfaceEntries() {
|
|
135
|
+
async function surfaceEntries(strict = false) {
|
|
136
136
|
const tree = await client.call("system.tree", { all_windows: true });
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
137
|
+
const records = (value) => {
|
|
138
|
+
if (strict && (!Array.isArray(value) || !value.every(isRecord)))
|
|
139
|
+
throw new Error("invalid cmux terminal inventory");
|
|
140
|
+
return list(value);
|
|
141
|
+
};
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
return records(tree.windows).flatMap((window) => records(window.workspaces).map((workspace) => ({ window, workspace }))).flatMap(({ window, workspace }) => records(workspace.panes).flatMap((pane) => records(pane.surfaces)).map((surface) => {
|
|
144
|
+
if (strict && (typeof surface.id !== "string" || !surface.id.trim() || typeof surface.type !== "string"
|
|
145
|
+
|| !["terminal", "browser"].includes(surface.type) || seen.has(surface.id)))
|
|
146
|
+
throw new Error("invalid cmux terminal inventory identity");
|
|
147
|
+
seen.add(str(surface.id) ?? "");
|
|
148
|
+
return {
|
|
149
|
+
id: str(surface.id) ?? "",
|
|
150
|
+
ref: str(surface.ref) ?? "",
|
|
151
|
+
workspace: str(workspace.ref),
|
|
152
|
+
workspaceTitle: str(workspace.title),
|
|
153
|
+
workspaceId: str(workspace.id) ?? "",
|
|
154
|
+
title: str(surface.title) ?? "",
|
|
155
|
+
tty: str(surface.tty),
|
|
156
|
+
type: str(surface.type) ?? "",
|
|
157
|
+
agent: hooked.get(str(surface.id) ?? "")?.agent ?? null,
|
|
158
|
+
cwd: hooked.get(str(surface.id) ?? "")?.cwd ?? null,
|
|
159
|
+
// The human's terminal: the focused surface of the selected workspace in a visible window.
|
|
160
|
+
focused: surface.focused === true && workspace.selected === true && window.visible !== false,
|
|
161
|
+
...(surface.focused === true && workspace.selected === true && window.visible !== false && window.key === true
|
|
162
|
+
? { selected: true } : {}),
|
|
163
|
+
};
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
async function retirementInventory() {
|
|
167
|
+
return (await surfaceEntries(true)).filter(surface => surface.type === "terminal").map(surface => surface.id);
|
|
153
168
|
}
|
|
154
|
-
async function surfaces(freshFocus = false) {
|
|
155
|
-
const entries = await surfaceEntries();
|
|
169
|
+
async function surfaces(freshFocus = false, strictInventory = false) {
|
|
170
|
+
const entries = await surfaceEntries(strictInventory);
|
|
156
171
|
const identities = await (options.identifyTerminals ?? terminal_process_1.identifyTerminalProcesses)(entries.filter((entry) => entry.type === "terminal" && entry.tty).map((entry) => entry.tty));
|
|
157
172
|
// Process lookup is awaited. On inspection paths, do not return focus from
|
|
158
173
|
// the tree sampled before that await. A moved/rebound TTY loses authority.
|
|
159
|
-
const current = freshFocus ? await surfaceEntries() : entries;
|
|
174
|
+
const current = freshFocus ? await surfaceEntries(strictInventory) : entries;
|
|
160
175
|
return current.map((entry) => {
|
|
161
176
|
const bound = entries.some(before => before.id === entry.id && before.tty === entry.tty && before.workspaceId === entry.workspaceId && before.type === entry.type);
|
|
162
177
|
const identity = entry.tty && bound ? identities.get(entry.tty.replace(/^\/dev\//, "")) : undefined;
|
|
@@ -216,6 +231,7 @@ function createCmuxHost(client, options = {}) {
|
|
|
216
231
|
async list() {
|
|
217
232
|
return (await surfaces()).filter((surface) => surface.type === "terminal").map(({ workspaceId: _workspaceId, type: _type, ...session }) => session);
|
|
218
233
|
},
|
|
234
|
+
retirementInventory,
|
|
219
235
|
read,
|
|
220
236
|
inspect,
|
|
221
237
|
async promptIfUnchanged(sessionId, line, expected, canSend) {
|
|
@@ -390,16 +406,40 @@ function createCmuxHost(client, options = {}) {
|
|
|
390
406
|
},
|
|
391
407
|
});
|
|
392
408
|
};
|
|
393
|
-
|
|
409
|
+
let lastObserved = [];
|
|
410
|
+
/** Inventory reads stay outside the judge queue; idle-hook terminals are observed but never rejudged here. */
|
|
394
411
|
const poll = async () => {
|
|
395
412
|
try {
|
|
396
|
-
const terminals = (await surfaces()).filter((surface) => surface.type === "terminal"
|
|
413
|
+
const terminals = (await surfaces(false, !!handlers.observed)).filter((surface) => surface.type === "terminal");
|
|
414
|
+
if (closed)
|
|
415
|
+
return;
|
|
397
416
|
const seen = new Set(terminals.map((surface) => surface.id));
|
|
398
417
|
for (const id of screens.keys())
|
|
399
418
|
if (!seen.has(id))
|
|
400
419
|
screens.delete(id);
|
|
420
|
+
const rows = [];
|
|
401
421
|
for (const surface of terminals) {
|
|
402
|
-
const
|
|
422
|
+
const idle = hooked.get(surface.id)?.idle === true;
|
|
423
|
+
if (idle)
|
|
424
|
+
screens.delete(surface.id);
|
|
425
|
+
if (idle && !handlers.observed)
|
|
426
|
+
continue;
|
|
427
|
+
let text;
|
|
428
|
+
try {
|
|
429
|
+
text = await read(surface.id, POLL_LINES);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
rows.push({ session: surface, activity: "unknown", observedAt: null, error: "read_failed" });
|
|
433
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_cmux_poll_error",
|
|
434
|
+
message: "could not read one cmux terminal; other terminals remain observable", meta: { error: (0, redact_1.redactSecrets)(String(error)) } });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (closed)
|
|
438
|
+
return;
|
|
439
|
+
rows.push({ session: surface, activity: surface.agent ? (0, prompt_state_1.inspectCodingPrompt)(surface.agent, text).activity : "unknown", observedAt: now() });
|
|
440
|
+
if (idle)
|
|
441
|
+
continue;
|
|
442
|
+
const hash = (0, node_crypto_1.createHash)("sha256").update(text).digest("hex");
|
|
403
443
|
const last = screens.get(surface.id);
|
|
404
444
|
const at = now();
|
|
405
445
|
if (!last)
|
|
@@ -413,9 +453,15 @@ function createCmuxHost(client, options = {}) {
|
|
|
413
453
|
handlers.returned({ sessionId: surface.id, transitionId: `cmux:quiet:${surface.id}:${last.changedAt}`, agent: surface.agent, cwd: surface.cwd ?? null, title: surface.title, workspaceTitle: surface.workspaceTitle, lastBody: null, backgroundWork: null });
|
|
414
454
|
}
|
|
415
455
|
}
|
|
456
|
+
if (closed)
|
|
457
|
+
return;
|
|
458
|
+
lastObserved = rows;
|
|
459
|
+
handlers.observed?.({ at: now(), sessions: rows });
|
|
416
460
|
}
|
|
417
461
|
catch (error) {
|
|
418
462
|
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_cmux_poll_error", message: "could not poll cmux terminals; the next poll retries", meta: { error: error.message } });
|
|
463
|
+
if (!closed)
|
|
464
|
+
handlers.observed?.({ at: now(), sessions: lastObserved, error: "host_unavailable" });
|
|
419
465
|
}
|
|
420
466
|
if (!closed)
|
|
421
467
|
cancelPoll = schedule(() => void poll(), exports.POLL_MS);
|
|
@@ -145,8 +145,8 @@ function bounded(value, name, max) {
|
|
|
145
145
|
function knownTarget(root, target, live, record) {
|
|
146
146
|
if (!validTarget(target))
|
|
147
147
|
throw new Error("invalid Shepherd session target");
|
|
148
|
-
const known = record ??
|
|
149
|
-
??
|
|
148
|
+
const known = record ?? live.find(t => t.host === target.host && t.session === target.session)
|
|
149
|
+
?? [...(0, returns_1.readReturns)(root).filter(validReturn)].reverse().find(r => r.host === target.host && r.session === target.session);
|
|
150
150
|
if (!known)
|
|
151
151
|
throw new Error("unknown session; no recorded or verified live Shepherd target");
|
|
152
152
|
if (known.host !== target.host || known.session !== target.session || known.cwd !== target.cwd || known.task !== target.task)
|
|
@@ -191,6 +191,9 @@ async function mutate(root, op, input, apply) {
|
|
|
191
191
|
throw new Error("request ID was already used with a different body");
|
|
192
192
|
return prior.receipt;
|
|
193
193
|
}
|
|
194
|
+
if (input.expectedRevision !== undefined && (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0 || input.expectedRevision !== state.revision)) {
|
|
195
|
+
throw new Error("Shepherd guidance revision conflict");
|
|
196
|
+
}
|
|
194
197
|
const clean = { actorId: input.actorId, requestId: input.requestId, at: new Date().toISOString(), kind: "operator instruction", returnId: null };
|
|
195
198
|
const result = apply(state, clean);
|
|
196
199
|
state.revision++;
|