seisin 0.3.0 → 0.4.2
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/README.md +12 -16
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/commands/init.js +13 -5
- package/src/commands/log.js +11 -4
- package/src/commands/requests.js +26 -18
- package/src/commands/run.js +15 -2
- package/src/commands/ui.js +59 -7
- package/src/config.js +79 -7
- package/src/hook.js +50 -0
- package/src/intake.js +32 -1
- package/src/log.js +194 -16
- package/src/mcp.js +63 -24
- package/src/notify.js +3 -3
- package/src/owners.js +140 -15
- package/src/render.js +13 -2
- package/src/requests.js +90 -13
- package/src/review.js +15 -7
- package/src/serve.js +68 -24
- package/src/surface.js +15 -1
- package/src/uilink.js +55 -0
- package/ui/index.html +30 -12
package/src/log.js
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* tries the parent's socket first for exactly that reason; writing the file
|
|
14
14
|
* directly is what happens when there is no parent, outside the box.
|
|
15
15
|
*/
|
|
16
|
-
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
|
|
16
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
|
-
import { dirname, join } from "node:path";
|
|
18
|
+
import { basename, dirname, join } from "node:path";
|
|
19
19
|
import { STATE_DIR, LOG_NAME } from "./layout.js";
|
|
20
20
|
import { send } from "./spool.js";
|
|
21
21
|
|
|
@@ -39,17 +39,97 @@ export function append(file, entry) {
|
|
|
39
39
|
try {
|
|
40
40
|
mkdirSync(dirname(file), { recursive: true });
|
|
41
41
|
withLock(file, () => {
|
|
42
|
+
// Rotate first, under the same lock: a full segment is renamed away and a
|
|
43
|
+
// fresh one seeded so the chain continues, then this line lands in the new
|
|
44
|
+
// segment. Done here so the size check and the append never race.
|
|
45
|
+
maybeRotate(file);
|
|
42
46
|
// `prev` goes last and is computed from the previous line exactly as it
|
|
43
47
|
// sits on disk, so verifying needs nothing but the file.
|
|
44
48
|
const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
|
|
45
49
|
appendFileSync(file, line + "\n");
|
|
46
|
-
});
|
|
50
|
+
}, { waitMs: envInt("SEISIN_LOG_LOCK_WAIT_MS", 6000) });
|
|
47
51
|
return true;
|
|
48
|
-
} catch {
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// A line that could not get the lock is gone, and a missing line does not
|
|
54
|
+
// break the chain. Say so beside the log, so `verify` can.
|
|
55
|
+
if (e?.code === "ELOCKED") {
|
|
56
|
+
try { appendFileSync(droppedPath(file), JSON.stringify({ at: new Date().toISOString(), role: entry?.role, event: entry?.event }) + "\n"); } catch {}
|
|
57
|
+
}
|
|
49
58
|
return false;
|
|
50
59
|
}
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
/** Where entries that could not be written are counted. */
|
|
63
|
+
export function droppedPath(file) {
|
|
64
|
+
return file + ".dropped";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countLines(file) {
|
|
68
|
+
try { return readFileSync(file, "utf8").split("\n").filter((l) => l.trim()).length; } catch { return 0; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* When the current segment fills, retire it and start a fresh one that still
|
|
73
|
+
* chains.
|
|
74
|
+
*
|
|
75
|
+
* An append-only file that only grows is the right default and the wrong
|
|
76
|
+
* forever: a busy team writes megabytes of it, and `verify` walks all of them
|
|
77
|
+
* on every run. Rotation caps the size of the file three tools tail while
|
|
78
|
+
* keeping the property that made the chain worth having — that a deletion shows.
|
|
79
|
+
*
|
|
80
|
+
* The retired segment keeps its own chain intact; the new segment's first line
|
|
81
|
+
* is a `rotated` marker whose `prev` is the retired segment's last hash, so the
|
|
82
|
+
* two verify as one chain across the cut (`verifyLog`). The comment on GENESIS
|
|
83
|
+
* asked for exactly this. Segments are numbered upward, `.1` the oldest, and
|
|
84
|
+
* the oldest beyond the keep count are dropped — the only place seisin removes
|
|
85
|
+
* a record, and it removes whole retired segments, never a line.
|
|
86
|
+
*/
|
|
87
|
+
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MiB per segment
|
|
88
|
+
const DEFAULT_KEEP = 5; // retired segments kept beside the current one
|
|
89
|
+
|
|
90
|
+
function envInt(name, fallback) {
|
|
91
|
+
const n = Number(process.env[name]);
|
|
92
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every segment of the log, oldest → newest, current last. */
|
|
96
|
+
export function logSegments(file) {
|
|
97
|
+
const dir = dirname(file);
|
|
98
|
+
const base = basename(file);
|
|
99
|
+
let names = [];
|
|
100
|
+
try { names = readdirSync(dir); } catch { return existsSync(file) ? [file] : []; }
|
|
101
|
+
const archives = names
|
|
102
|
+
.map((n) => {
|
|
103
|
+
if (!n.startsWith(base + ".")) return null;
|
|
104
|
+
const seq = Number(n.slice(base.length + 1));
|
|
105
|
+
return Number.isInteger(seq) && seq > 0 ? { seq, path: join(dir, n) } : null;
|
|
106
|
+
})
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.sort((a, b) => a.seq - b.seq)
|
|
109
|
+
.map((a) => a.path);
|
|
110
|
+
if (existsSync(file)) archives.push(file);
|
|
111
|
+
return archives;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function maybeRotate(file) {
|
|
115
|
+
let sz = 0;
|
|
116
|
+
try { sz = statSync(file).size; } catch { return; }
|
|
117
|
+
if (sz < envInt("SEISIN_LOG_MAX_BYTES", DEFAULT_MAX_BYTES)) return;
|
|
118
|
+
|
|
119
|
+
const tail = hashOf(lastLine(file));
|
|
120
|
+
const archives = logSegments(file).filter((p) => p !== file);
|
|
121
|
+
const seqs = archives.map((p) => Number(p.slice((file + ".").length))).filter(Number.isInteger);
|
|
122
|
+
const next = (seqs.length ? Math.max(...seqs) : 0) + 1;
|
|
123
|
+
renameSync(file, `${file}.${next}`);
|
|
124
|
+
// Seed the new current so the first real append chains from the retired tail.
|
|
125
|
+
const marker = JSON.stringify({ at: new Date().toISOString(), event: "rotated", from: `${basename(file)}.${next}`, prev: tail });
|
|
126
|
+
appendFileSync(file, marker + "\n");
|
|
127
|
+
|
|
128
|
+
const keep = envInt("SEISIN_LOG_KEEP", DEFAULT_KEEP);
|
|
129
|
+
const retired = logSegments(file).filter((p) => p !== file); // oldest → newest, includes the new archive
|
|
130
|
+
for (let i = 0; i < retired.length - keep; i++) { try { unlinkSync(retired[i]); } catch {} }
|
|
131
|
+
}
|
|
132
|
+
|
|
53
133
|
/**
|
|
54
134
|
* The log is chained: every line carries `prev`, the hash of the line before it.
|
|
55
135
|
*
|
|
@@ -90,23 +170,73 @@ function lastLine(file) {
|
|
|
90
170
|
/**
|
|
91
171
|
* One writer at a time. Several roles of a round run at once and write this
|
|
92
172
|
* same file; two of them reading the same last line would fork the chain.
|
|
93
|
-
*
|
|
173
|
+
*
|
|
174
|
+
* The lock names its holder (pid and a nonce). A lock whose holder is gone is
|
|
175
|
+
* taken over at once; one whose holder cannot be read is taken over once it is
|
|
176
|
+
* older than `staleMs`. The wait outlasts that age on purpose: a writer that
|
|
177
|
+
* died holding the lock can never make the next one give up, so the only entry
|
|
178
|
+
* ever dropped is one that waited on a writer still alive — and that drop is
|
|
179
|
+
* counted beside the log (see `append`), because a missing line does not break
|
|
180
|
+
* the chain and would otherwise not show.
|
|
94
181
|
*/
|
|
95
|
-
function withLock(file, fn) {
|
|
182
|
+
export function withLock(file, fn, { waitMs = 6000, staleMs = 5000 } = {}) {
|
|
96
183
|
const lock = file + ".lock";
|
|
97
|
-
const deadline = Date.now() +
|
|
184
|
+
const deadline = Date.now() + waitMs;
|
|
185
|
+
const mine = `${process.pid} ${createHash("sha256").update(String(Math.random()) + Date.now()).digest("hex").slice(0, 12)}\n`;
|
|
186
|
+
let acquired = false;
|
|
98
187
|
for (;;) {
|
|
99
188
|
try {
|
|
100
|
-
|
|
189
|
+
const fd = openSync(lock, "wx");
|
|
190
|
+
try { writeSync(fd, mine); } finally { closeSync(fd); }
|
|
191
|
+
acquired = true;
|
|
101
192
|
break;
|
|
102
193
|
} catch (e) {
|
|
103
194
|
if (e.code !== "EEXIST") throw e;
|
|
104
|
-
|
|
105
|
-
|
|
195
|
+
if (reapable(lock, staleMs)) continue;
|
|
196
|
+
// Never write without the lock. Doing so forks the hash chain; deleting
|
|
197
|
+
// the lock in `finally` then admits still more writers while its owner is
|
|
198
|
+
// active. The log is bookkeeping and append() may fail without taking the
|
|
199
|
+
// agent down, so dropping this entry is safer than fabricating evidence.
|
|
200
|
+
if (Date.now() > deadline) {
|
|
201
|
+
const error = new Error(`could not acquire log lock within ${waitMs} ms`);
|
|
202
|
+
error.code = "ELOCKED";
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
106
205
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
107
206
|
}
|
|
108
207
|
}
|
|
109
|
-
try { return fn(); }
|
|
208
|
+
try { return fn(); }
|
|
209
|
+
finally {
|
|
210
|
+
// Remove it only while it is still ours: if it was taken over, it now
|
|
211
|
+
// belongs to someone else.
|
|
212
|
+
if (acquired) try { if (readFileSync(lock, "utf8") === mine) unlinkSync(lock); } catch {}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** True if the process is still there. EPERM means it is, and not ours to signal. */
|
|
217
|
+
function alive(pid) {
|
|
218
|
+
try { process.kill(pid, 0); return true; }
|
|
219
|
+
catch (e) { return e.code !== "ESRCH"; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Removes a lock whose holder is gone, and says whether it did.
|
|
224
|
+
*
|
|
225
|
+
* The content is read again just before removing it, so a waiter that decided
|
|
226
|
+
* on one lock does not remove a newer one another waiter has just taken.
|
|
227
|
+
*/
|
|
228
|
+
function reapable(lock, staleMs) {
|
|
229
|
+
let seen, mtime;
|
|
230
|
+
try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; } catch { return true; }
|
|
231
|
+
const pid = Number(seen.split(" ")[0]);
|
|
232
|
+
const named = Number.isInteger(pid) && pid > 0;
|
|
233
|
+
// A named holder that is alive is waited on, however long it takes: its age
|
|
234
|
+
// says nothing about whether it is done. An unnamed one (an older seisin, or
|
|
235
|
+
// killed between creating the file and writing its name) goes by age.
|
|
236
|
+
const gone = named ? !alive(pid) : Date.now() - mtime > staleMs;
|
|
237
|
+
if (!gone) return false;
|
|
238
|
+
try { if (readFileSync(lock, "utf8") !== seen) return true; unlinkSync(lock); } catch {}
|
|
239
|
+
return true;
|
|
110
240
|
}
|
|
111
241
|
|
|
112
242
|
/**
|
|
@@ -116,8 +246,8 @@ function withLock(file, fn) {
|
|
|
116
246
|
* unchained prefix, not reported as tampering. After the first chained line,
|
|
117
247
|
* a line without `prev` is itself a break.
|
|
118
248
|
*/
|
|
119
|
-
|
|
120
|
-
const out = { lines: 0, unchained: 0, chained: 0, breaks: [] };
|
|
249
|
+
function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
|
|
250
|
+
const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null };
|
|
121
251
|
if (!existsSync(file)) return out;
|
|
122
252
|
let previous = null;
|
|
123
253
|
let started = false;
|
|
@@ -129,15 +259,54 @@ export function verifyChain(file) {
|
|
|
129
259
|
const prev = e?.prev;
|
|
130
260
|
if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
|
|
131
261
|
started = true;
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
262
|
+
const first = out.chained === 0 && out.unchained === 0;
|
|
263
|
+
// A rotation marker leading a segment we have nothing to check it against —
|
|
264
|
+
// a file verified on its own, or the oldest segment left after the prefix
|
|
265
|
+
// was pruned for retention (seed still GENESIS) — is accepted rather than
|
|
266
|
+
// called a break. For any later segment the seed is the real previous tail,
|
|
267
|
+
// so the marker's `prev` IS checked, and a whole segment deleted or
|
|
268
|
+
// reordered in the middle shows.
|
|
269
|
+
const rotationStart = first && e?.event === "rotated" && seed === GENESIS;
|
|
270
|
+
const expected = first ? seed : hashOf(previous);
|
|
271
|
+
const ok = rotationStart || prev === expected || (first && prev === hashOf(previous));
|
|
272
|
+
if (!ok) out.breaks.push({ line: startLine + out.lines, expected, found: prev ?? null });
|
|
135
273
|
out.chained++;
|
|
136
274
|
previous = line;
|
|
137
275
|
}
|
|
276
|
+
out.tail = previous;
|
|
138
277
|
return out;
|
|
139
278
|
}
|
|
140
279
|
|
|
280
|
+
export function verifyChain(file, seed = GENESIS) {
|
|
281
|
+
const r = walk(file, { seed });
|
|
282
|
+
return { lines: r.lines, unchained: r.unchained, chained: r.chained, breaks: r.breaks };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Verify the whole log, across every rotated segment, as one chain.
|
|
287
|
+
*
|
|
288
|
+
* Each retired segment is walked in order, and its last hash becomes the seed
|
|
289
|
+
* the next segment's `rotated` marker must match — so deleting or reordering a
|
|
290
|
+
* whole segment shows, not only an edit inside one. With no rotation there is a
|
|
291
|
+
* single segment and this is exactly `verifyChain`. What it cannot see is a
|
|
292
|
+
* segment dropped off the *oldest* end by the keep limit: that is retention, not
|
|
293
|
+
* tampering, and the count of segments is reported so the drop is not silent.
|
|
294
|
+
*/
|
|
295
|
+
export function verifyLog(file) {
|
|
296
|
+
const segs = logSegments(file);
|
|
297
|
+
const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length, dropped: countLines(droppedPath(file)) };
|
|
298
|
+
let seed = GENESIS;
|
|
299
|
+
for (const seg of segs) {
|
|
300
|
+
const r = walk(seg, { seed, startLine: agg.lines });
|
|
301
|
+
agg.lines += r.lines;
|
|
302
|
+
agg.unchained += r.unchained;
|
|
303
|
+
agg.chained += r.chained;
|
|
304
|
+
agg.breaks.push(...r.breaks);
|
|
305
|
+
seed = hashOf(r.tail);
|
|
306
|
+
}
|
|
307
|
+
return agg;
|
|
308
|
+
}
|
|
309
|
+
|
|
141
310
|
/** Reads entries, newest last. A malformed line is skipped, never fatal. */
|
|
142
311
|
export function read(file, { role, verdict, since, limit = 0 } = {}) {
|
|
143
312
|
if (!existsSync(file)) return [];
|
|
@@ -150,6 +319,7 @@ export function read(file, { role, verdict, since, limit = 0 } = {}) {
|
|
|
150
319
|
} catch {
|
|
151
320
|
continue; // a half-written line from a killed process is not an error
|
|
152
321
|
}
|
|
322
|
+
if (e.event === "rotated") continue; // a structural marker, not a decision
|
|
153
323
|
if (role && e.role !== role) continue;
|
|
154
324
|
if (verdict && e.verdict !== verdict) continue;
|
|
155
325
|
if (since && e.at < since) continue;
|
|
@@ -179,6 +349,14 @@ export function observed(entries) {
|
|
|
179
349
|
const roles = new Map();
|
|
180
350
|
for (const e of entries) {
|
|
181
351
|
if (!e.role || !e.target) continue;
|
|
352
|
+
// A line the parent flagged as `disputed` is one whose verdict did not
|
|
353
|
+
// match the policy the run started with — either the config changed under
|
|
354
|
+
// it, or the line was not written by the hook at all. Either way it is not
|
|
355
|
+
// evidence of what the role legitimately reached for, so it must not become
|
|
356
|
+
// policy: `init --from-observations` builds `writes`/`keys` from here, and a
|
|
357
|
+
// process inside the box could otherwise send `verdict:"observed"` lines for
|
|
358
|
+
// paths outside its territory and have them proposed as its own.
|
|
359
|
+
if (e.disputed) continue;
|
|
182
360
|
const r = roles.get(e.role) ?? { writes: new Set(), keys: new Set() };
|
|
183
361
|
if (e.action === "read" && e.kind === "key") r.keys.add(e.target);
|
|
184
362
|
else if (e.action === "write") r.writes.add(e.target);
|
package/src/mcp.js
CHANGED
|
@@ -31,7 +31,7 @@ import { loadConfig, findConfig } from "./config.js";
|
|
|
31
31
|
import { inspect } from "./inspect.js";
|
|
32
32
|
import { explain, ownersOf } from "./owners.js";
|
|
33
33
|
import { settingsFor } from "./srt.js";
|
|
34
|
-
import { pending, requestsPath, grantFor, refuseIfBarred, markStale } from "./requests.js";
|
|
34
|
+
import { pending, requestsPath, grantFor, refuseIfBarred, markStale, shellId } from "./requests.js";
|
|
35
35
|
import { read, logPath } from "./log.js";
|
|
36
36
|
import { causesOf } from "./serve.js";
|
|
37
37
|
import { walls, wasted } from "./walls.js";
|
|
@@ -100,13 +100,22 @@ const TOOLS = [
|
|
|
100
100
|
name: "seisin_causes",
|
|
101
101
|
description:
|
|
102
102
|
"Denials grouped by what was denied — by path, and one level coarser by the name at " +
|
|
103
|
-
"the end of it —
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
|
|
103
|
+
"the end of it — over the whole log, or since an ISO date. The counts are history: " +
|
|
104
|
+
"what was refused. What is measured against the policy as it stands is where each " +
|
|
105
|
+
"path is now — `standing`: unowned (a decision nobody has made), owned, protected " +
|
|
106
|
+
"(closed to every role on purpose, nothing to grant) or outside (a port, a key, or " +
|
|
107
|
+
"outside the repository) — and, per cause, `stillRefused`, how many of the roles " +
|
|
108
|
+
"that hit it would hit it today. `seisin_activity` has the raw events; this has " +
|
|
109
|
+
"them read against the policy. Reach for it before proposing a change: a day that " +
|
|
110
|
+
"is three quarters one filename is a tooling problem, and the same volume spread " +
|
|
111
|
+
"across unrelated unowned paths is a question about who owns what. Read-only.",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
limit: { type: "number" },
|
|
116
|
+
since: { type: "string", description: "ISO date; only refusals at or after it" },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
110
119
|
},
|
|
111
120
|
{
|
|
112
121
|
name: "seisin_walls",
|
|
@@ -127,8 +136,10 @@ const TOOLS = [
|
|
|
127
136
|
"does not grant. The approval is a human action in another channel.",
|
|
128
137
|
inputSchema: {
|
|
129
138
|
type: "object",
|
|
130
|
-
properties: {
|
|
131
|
-
|
|
139
|
+
properties: {
|
|
140
|
+
id: { type: "string", description: "the request's id from seisin_requests — preferred: it does not move when the queue does" },
|
|
141
|
+
number: { type: "number", description: "position in seisin_requests (legacy; the draft still names the id)" },
|
|
142
|
+
},
|
|
132
143
|
},
|
|
133
144
|
},
|
|
134
145
|
];
|
|
@@ -166,6 +177,8 @@ const HANDLERS = {
|
|
|
166
177
|
// not enforced is documentation.
|
|
167
178
|
if (action !== "read" && action !== "write" && action !== "mcp")
|
|
168
179
|
throw new Error(`action must be "read", "write" or "mcp", got ${JSON.stringify(action)}`);
|
|
180
|
+
if (typeof role !== "string" || !role)
|
|
181
|
+
throw new Error("role must be a non-empty string");
|
|
169
182
|
if (typeof target !== "string" || !target)
|
|
170
183
|
throw new Error("target must be a non-empty path");
|
|
171
184
|
const cfg = config();
|
|
@@ -182,14 +195,14 @@ const HANDLERS = {
|
|
|
182
195
|
const queue = markStale(pending(requestsPath(cfg.root)), read(logPath(cfg.root)));
|
|
183
196
|
return {
|
|
184
197
|
pending: queue.map((r, i) => ({
|
|
185
|
-
number: i + 1, role: r.role, action: r.action,
|
|
198
|
+
number: i + 1, id: r.key, role: r.role, action: r.action,
|
|
186
199
|
wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
|
|
187
200
|
...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
|
|
188
201
|
})),
|
|
189
202
|
// Said in the payload and not only in the tool description, because a
|
|
190
203
|
// model reading this is deciding what to do next.
|
|
191
204
|
note: queue.length
|
|
192
|
-
? "Approving is not available through MCP. Tell the operator to run: seisin grant <
|
|
205
|
+
? "Approving is not available through MCP. Tell the operator to run: seisin grant '<id>'"
|
|
193
206
|
: "nothing waiting",
|
|
194
207
|
};
|
|
195
208
|
},
|
|
@@ -203,13 +216,21 @@ const HANDLERS = {
|
|
|
203
216
|
* and asks it to re-derive the grouping — without the policy, which is the
|
|
204
217
|
* half that makes the grouping mean anything.
|
|
205
218
|
*/
|
|
206
|
-
seisin_causes({ limit }) {
|
|
219
|
+
seisin_causes({ limit, since }) {
|
|
207
220
|
const cfg = config();
|
|
208
|
-
|
|
221
|
+
// The whole log, as the console reads it. This read the last 4000 lines
|
|
222
|
+
// after the console had stopped doing so, and the two then disagreed about
|
|
223
|
+
// the same log — 3,999 refusals here against 6,630 there, measured — with
|
|
224
|
+
// nothing telling the agent its window was half of the person's.
|
|
225
|
+
const t = since ? Date.parse(since) : NaN;
|
|
226
|
+
const from = Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
227
|
+
const c = causesOf(cfg, read(logPath(cfg.root), from ? { since: from } : {}));
|
|
209
228
|
return {
|
|
210
229
|
total: c.total,
|
|
211
230
|
distinct: c.distinct,
|
|
231
|
+
...(from && { since: from }),
|
|
212
232
|
unowned: c.unowned,
|
|
233
|
+
standing: c.standing,
|
|
213
234
|
families: c.families,
|
|
214
235
|
causes: typeof limit === "number" ? c.causes.slice(0, limit) : c.causes,
|
|
215
236
|
};
|
|
@@ -237,12 +258,23 @@ const HANDLERS = {
|
|
|
237
258
|
return { entries: read(logPath(cfg.root), { role, verdict, limit: n }) };
|
|
238
259
|
},
|
|
239
260
|
|
|
240
|
-
seisin_draft_grant({ number }) {
|
|
241
|
-
if (!Number.isInteger(Number(number)) || Number(number) < 1)
|
|
242
|
-
throw new Error("number must be a positive integer from seisin_requests");
|
|
261
|
+
seisin_draft_grant({ id, number } = {}) {
|
|
243
262
|
const cfg = config();
|
|
244
|
-
const
|
|
245
|
-
|
|
263
|
+
const queue = pending(requestsPath(cfg.root));
|
|
264
|
+
let req;
|
|
265
|
+
if (typeof id === "string" && id) {
|
|
266
|
+
req = queue.find((r) => r.key === id);
|
|
267
|
+
if (!req) throw new Error(`request ${id} is not pending`);
|
|
268
|
+
} else {
|
|
269
|
+
if (!Number.isInteger(Number(number)) || Number(number) < 1)
|
|
270
|
+
throw new Error("id (preferred) or number from seisin_requests is required");
|
|
271
|
+
req = queue[Number(number) - 1];
|
|
272
|
+
if (!req) throw new Error(`no pending request #${number}`);
|
|
273
|
+
}
|
|
274
|
+
// The command is written against the id, whatever was asked with: a
|
|
275
|
+
// number is a position in a queue agents keep writing to, and the person
|
|
276
|
+
// runs this later, against the queue as it is then.
|
|
277
|
+
const ref = shellId(req.key);
|
|
246
278
|
|
|
247
279
|
// The same refusal `seisin grant` and the console apply, asked here first.
|
|
248
280
|
// Without it this drafted, as approvable, a request that `never_writes`
|
|
@@ -255,7 +287,7 @@ const HANDLERS = {
|
|
|
255
287
|
ownedBy: req.owners,
|
|
256
288
|
applyWith: null,
|
|
257
289
|
note: "Not approvable as it stands: never_writes wins over writes. Decline it with " +
|
|
258
|
-
`\`seisin decline ${
|
|
290
|
+
`\`seisin decline ${ref}\`, or remove the never_writes entry if the subtraction is wrong.`,
|
|
259
291
|
};
|
|
260
292
|
}
|
|
261
293
|
const field = req.action === "read" ? "keys" : "writes";
|
|
@@ -263,7 +295,8 @@ const HANDLERS = {
|
|
|
263
295
|
wouldAdd: { role: req.role, field, value: req.grant },
|
|
264
296
|
preview: `[roles.${req.role}]\n${field} = [ …, "${req.grant}" ] # asked ${req.times}×`,
|
|
265
297
|
ownedBy: req.owners,
|
|
266
|
-
|
|
298
|
+
id: req.key,
|
|
299
|
+
applyWith: `seisin grant ${ref} --reason "<why>"`,
|
|
267
300
|
note: "Not applied. seisin's MCP server writes nothing — a person runs the command above.",
|
|
268
301
|
};
|
|
269
302
|
},
|
|
@@ -284,7 +317,10 @@ function handle(message, { write, version }) {
|
|
|
284
317
|
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
|
|
285
318
|
const fail = (id, code, msg) => send({ jsonrpc: "2.0", id, error: { code, message: msg } });
|
|
286
319
|
|
|
287
|
-
const { id, method
|
|
320
|
+
const { id, method } = message;
|
|
321
|
+
// `?? {}` so an explicit null params (not just missing) does not crash the
|
|
322
|
+
// dispatcher and leave the request unanswered.
|
|
323
|
+
const params = message.params ?? {};
|
|
288
324
|
|
|
289
325
|
// A notification has no id and takes no answer. Replying to one is a protocol
|
|
290
326
|
// error that some clients tolerate and others hang on.
|
|
@@ -310,8 +346,11 @@ function handle(message, { write, version }) {
|
|
|
310
346
|
case "tools/list":
|
|
311
347
|
return reply(id, { tools: TOOLS });
|
|
312
348
|
case "tools/call": {
|
|
313
|
-
|
|
314
|
-
|
|
349
|
+
// hasOwn, not a bare lookup: `params.name` of "constructor" or
|
|
350
|
+
// "hasOwnProperty" would otherwise resolve to a function on Object's
|
|
351
|
+
// prototype and be invoked as a tool.
|
|
352
|
+
const fn = Object.hasOwn(HANDLERS, params.name) ? HANDLERS[params.name] : null;
|
|
353
|
+
if (typeof fn !== "function") return fail(id, -32602, `unknown tool ${JSON.stringify(params.name)}`);
|
|
315
354
|
try {
|
|
316
355
|
const result = fn(params.arguments ?? {});
|
|
317
356
|
// Content, not a bare object: every client renders `content`, and only
|
package/src/notify.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { readFileSync } from "node:fs";
|
|
26
26
|
import { basename, join } from "node:path";
|
|
27
|
-
import { keyOf, pending, requestsPath, grantFor } from "./requests.js";
|
|
27
|
+
import { keyOf, pending, requestsPath, grantFor, shellId } from "./requests.js";
|
|
28
28
|
|
|
29
29
|
export const FORMATS = ["text", "json", "slack"];
|
|
30
30
|
export const ENV_URL = "SEISIN_NOTIFY_URL";
|
|
@@ -46,13 +46,13 @@ export function message(config, req, number, format = "text") {
|
|
|
46
46
|
const owner = req.owners?.length ? `It belongs to ${req.owners.join(", ")}.` : "Nobody owns it.";
|
|
47
47
|
const text =
|
|
48
48
|
`seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
|
|
49
|
-
`Approve: seisin grant ${
|
|
49
|
+
`Approve: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
|
|
50
50
|
if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
|
|
51
51
|
if (format === "json")
|
|
52
52
|
return {
|
|
53
53
|
type: "application/json",
|
|
54
54
|
body: JSON.stringify({
|
|
55
|
-
text, repo: basename(config.root), number, role: req.role, action: req.action,
|
|
55
|
+
text, repo: basename(config.root), number, id: keyOf(req), role: req.role, action: req.action,
|
|
56
56
|
target: req.target, owners: req.owners ?? [], grant: grantFor(req),
|
|
57
57
|
}),
|
|
58
58
|
};
|