@idleware/ringi 0.0.0-stage → 0.0.0
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 +83 -2
- package/package.json +51 -4
- package/src/main/resources/profiles/a2a-1.json +43 -0
- package/src/main/resources/skill/ringi/SKILL.md +72 -0
- package/target/dts/app/agent.d.ts +104 -0
- package/target/dts/app/budget.d.ts +36 -0
- package/target/dts/app/context.d.ts +19 -0
- package/target/dts/app/hubs.d.ts +38 -0
- package/target/dts/app/overview.d.ts +25 -0
- package/target/dts/app/room.d.ts +76 -0
- package/target/dts/app/rooms.d.ts +26 -0
- package/target/dts/app/session.d.ts +60 -0
- package/target/dts/app/view.d.ts +64 -0
- package/target/dts/cli/agent.d.ts +10 -0
- package/target/dts/cli/args.d.ts +13 -0
- package/target/dts/cli/context.d.ts +6 -0
- package/target/dts/cli/index.d.ts +5 -0
- package/target/dts/cli/operator.d.ts +8 -0
- package/target/dts/cli/session.d.ts +7 -0
- package/target/dts/cli/shared.d.ts +20 -0
- package/target/dts/cli/skill.d.ts +4 -0
- package/target/dts/cli.d.ts +3 -0
- package/target/dts/domain/decisions.d.ts +65 -0
- package/target/dts/domain/index.d.ts +2 -0
- package/target/dts/domain/profiles.d.ts +39 -0
- package/target/dts/domain/signals.d.ts +37 -0
- package/target/dts/home/index.d.ts +21 -0
- package/target/dts/hub/index.d.ts +66 -0
- package/target/dts/hub/signals.d.ts +4 -0
- package/target/dts/index.d.ts +8 -0
- package/target/dts/lib/index.d.ts +18 -0
- package/target/dts/protocol/files.d.ts +9 -0
- package/target/dts/protocol/index.d.ts +72 -0
- package/target/dts/runtime/reader.d.ts +34 -0
- package/target/dts/runtime/session.d.ts +42 -0
- package/target/dts/runtime/watch.d.ts +17 -0
- package/target/dts/store/git/index.d.ts +64 -0
- package/target/dts/store/git/remote.d.ts +70 -0
- package/target/dts/store/index.d.ts +102 -0
- package/target/dts/store/kafka/index.d.ts +2 -0
- package/target/dts/store/temporal/index.d.ts +2 -0
- package/target/esm/app/agent.js +83 -0
- package/target/esm/app/budget.js +94 -0
- package/target/esm/app/context.js +15 -0
- package/target/esm/app/hubs.js +75 -0
- package/target/esm/app/overview.js +38 -0
- package/target/esm/app/room.js +126 -0
- package/target/esm/app/rooms.js +119 -0
- package/target/esm/app/session.js +96 -0
- package/target/esm/app/view.js +51 -0
- package/target/esm/cli/agent.js +39 -0
- package/target/esm/cli/args.js +60 -0
- package/target/esm/cli/context.js +7 -0
- package/target/esm/cli/index.js +124 -0
- package/target/esm/cli/operator.js +146 -0
- package/target/esm/cli/session.js +38 -0
- package/target/esm/cli/shared.js +142 -0
- package/target/esm/cli/skill.js +53 -0
- package/target/esm/cli.js +13 -0
- package/target/esm/domain/decisions.js +177 -0
- package/target/esm/domain/index.js +15 -0
- package/target/esm/domain/profiles.js +95 -0
- package/target/esm/domain/signals.js +128 -0
- package/target/esm/home/index.js +87 -0
- package/target/esm/hub/index.js +211 -0
- package/target/esm/hub/signals.js +26 -0
- package/target/esm/index.js +8 -0
- package/target/esm/lib/index.js +201 -0
- package/target/esm/protocol/files.js +18 -0
- package/target/esm/protocol/index.js +216 -0
- package/target/esm/runtime/reader.js +63 -0
- package/target/esm/runtime/session.js +111 -0
- package/target/esm/runtime/watch.js +303 -0
- package/target/esm/store/git/index.js +345 -0
- package/target/esm/store/git/remote.js +699 -0
- package/target/esm/store/index.js +14 -0
- package/target/esm/store/kafka/index.js +2 -0
- package/target/esm/store/temporal/index.js +2 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// watch: headless turns for one agent of one room (core-1 §7). It holds that agent's seat, answers heartbeats in
|
|
2
|
+
// code, and spends only within a grant (decisions-1) or its own run budget. Usage receipts are written here, by the
|
|
3
|
+
// runner, never by the model.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import * as D from '../domain/decisions.js';
|
|
6
|
+
import { HOME, OPERATOR, config, locked } from '../home/index.js';
|
|
7
|
+
import { HIDDEN, append, openHub, openRoom, pongText } from '../hub/index.js';
|
|
8
|
+
import { loadRoomUnlocked, loadState, readerLock, saveState } from './reader.js';
|
|
9
|
+
import { isOpen, taskFor, taskOf } from '../domain/profiles.js';
|
|
10
|
+
import { compose, link, unread, wakes } from '../protocol/index.js';
|
|
11
|
+
import { dropSession, liveness, parseSelector, pidAlive, readSession, saveSession, seatHolder, whoAmI } from './session.js';
|
|
12
|
+
import { fail, note, short, sleep, stamp } from '../lib/index.js';
|
|
13
|
+
// One turn: the command runs in a process group of its own (POSIX). At the deadline, or when the command itself
|
|
14
|
+
// ends, whatever is left of the group is asked to stop and, after a grace, killed; the turn ends when the group is
|
|
15
|
+
// empty, so no descendant outlives the run it was counted as.
|
|
16
|
+
const GRACE = 5000;
|
|
17
|
+
const groupAlive = (pid) => {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(-pid, 0);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return e.code === 'EPERM';
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const signalGroup = (pid, sig) => {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(-pid, sig);
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
};
|
|
32
|
+
const turn = (cmd, seconds, env) => new Promise(resolve => {
|
|
33
|
+
const t0 = Date.now();
|
|
34
|
+
const child = spawn('sh', ['-c', cmd], { stdio: 'inherit', env, detached: true });
|
|
35
|
+
const pid = child.pid;
|
|
36
|
+
let timedOut = false;
|
|
37
|
+
let exit = null;
|
|
38
|
+
const end = async () => {
|
|
39
|
+
signalGroup(pid, 'SIGTERM');
|
|
40
|
+
const until = Date.now() + GRACE;
|
|
41
|
+
while (groupAlive(pid) && Date.now() < until)
|
|
42
|
+
await sleep(50);
|
|
43
|
+
if (groupAlive(pid))
|
|
44
|
+
signalGroup(pid, 'SIGKILL');
|
|
45
|
+
while (groupAlive(pid))
|
|
46
|
+
await sleep(50);
|
|
47
|
+
resolve({ code: exit?.code ?? null, signal: exit?.signal ?? null, timedOut, seconds: Math.round((Date.now() - t0) / 1000) });
|
|
48
|
+
};
|
|
49
|
+
// At the deadline: SIGTERM to the group, and SIGKILL after the grace even if the shell ignores it.
|
|
50
|
+
let hard;
|
|
51
|
+
const timer = seconds ? setTimeout(() => {
|
|
52
|
+
timedOut = true;
|
|
53
|
+
signalGroup(pid, 'SIGTERM');
|
|
54
|
+
hard = setTimeout(() => signalGroup(pid, 'SIGKILL'), GRACE);
|
|
55
|
+
}, seconds * 1000) : undefined;
|
|
56
|
+
const stopAll = () => signalGroup(pid, 'SIGTERM');
|
|
57
|
+
process.once('SIGINT', stopAll);
|
|
58
|
+
process.once('SIGTERM', stopAll);
|
|
59
|
+
child.on('exit', (code, signal) => {
|
|
60
|
+
exit = { code, signal };
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
clearTimeout(hard);
|
|
63
|
+
process.removeListener('SIGINT', stopAll);
|
|
64
|
+
process.removeListener('SIGTERM', stopAll);
|
|
65
|
+
void end();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
export const watch = async (w, log = note) => {
|
|
69
|
+
for (const [name, value] of [['interval', w.interval], ['window', w.window]])
|
|
70
|
+
if (value !== undefined && (!Number.isFinite(value) || value <= 0))
|
|
71
|
+
fail(`watch ${name} must be positive seconds`);
|
|
72
|
+
for (const [name, value] of [['max-runs', w.maxRuns], ['max-hop', w.maxHop]])
|
|
73
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
|
|
74
|
+
fail(`watch ${name} must be a nonnegative integer`);
|
|
75
|
+
const sel = parseSelector(w.seat);
|
|
76
|
+
const cmd = w.exec;
|
|
77
|
+
const H0 = openHub(sel.hub ?? w.hub ?? config().hub);
|
|
78
|
+
const R0 = openRoom(H0, sel.room);
|
|
79
|
+
const me = sel.agent;
|
|
80
|
+
if (R0.archived)
|
|
81
|
+
fail(`${R0.name} is archived`);
|
|
82
|
+
if (!R0.members[me])
|
|
83
|
+
fail(`${me} is not a member of ${R0.name}; the operator adds it with a join, or watch another agent`);
|
|
84
|
+
// Each watcher process is its own session, so two of them can never share one seat record.
|
|
85
|
+
const raw = `watch-${H0.name}-${R0.genesis.slice(0, 8)}-${me}-${process.pid}`;
|
|
86
|
+
const handle = `env:${raw}`;
|
|
87
|
+
locked('sessions', () => {
|
|
88
|
+
const holder = seatHolder(H0.name, R0.genesis, me, handle);
|
|
89
|
+
const handoff = w.handoff && holder?.product !== 'watch' && holder?.handle === whoAmI().handle;
|
|
90
|
+
if (w.handoff && !handoff)
|
|
91
|
+
fail('watch --handoff needs the invoking chat to own this seat');
|
|
92
|
+
if (handoff && holder.state === 'stopped')
|
|
93
|
+
fail('this chat is stopped; continue it before a handoff');
|
|
94
|
+
if (holder && !handoff && (holder.product !== 'watch' || (holder.pid && pidAlive(holder.pid))))
|
|
95
|
+
fail(`${R0.name}/${me} is held by session ${holder.handle} (${liveness(holder)}); one seat per agent. Its chat may use watch --handoff`);
|
|
96
|
+
if (holder)
|
|
97
|
+
dropSession(holder.handle);
|
|
98
|
+
saveSession({ handle, product: 'watch', hub: H0.name, room: R0.name, genesis: R0.genesis, agent: me, scope: w.scope ?? holder?.scope ?? null,
|
|
99
|
+
state: holder?.state === 'stopped' ? 'stopped' : 'active', since: stamp(), lastSeen: stamp(), pid: process.pid,
|
|
100
|
+
takenFrom: handoff ? holder.handle : holder?.takenFrom });
|
|
101
|
+
});
|
|
102
|
+
const interval = (w.interval ?? 5) * 1000;
|
|
103
|
+
const allow = w.from ? new Set(w.from) : null;
|
|
104
|
+
const maxHop = w.maxHop ?? 8;
|
|
105
|
+
const maxRuns = w.maxRuns ?? 10;
|
|
106
|
+
const span = w.window ?? 86400;
|
|
107
|
+
let runSeq = 0;
|
|
108
|
+
for (;;) {
|
|
109
|
+
const s = locked('sessions', () => {
|
|
110
|
+
const cur = readSession(handle);
|
|
111
|
+
if (cur?.state === 'active')
|
|
112
|
+
saveSession({ ...cur, lastSeen: stamp(), pid: process.pid });
|
|
113
|
+
return cur;
|
|
114
|
+
});
|
|
115
|
+
if (!s) {
|
|
116
|
+
log(`watch: the seat of ${me} was released or taken over; stopping`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (s.state === 'stopped') {
|
|
120
|
+
if (w.once) {
|
|
121
|
+
log('watch: stopped by the operator');
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
await sleep(interval);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const H = openHub(H0.name, { refresh: true });
|
|
128
|
+
const R = openRoom(H, R0.name, { quiet: true });
|
|
129
|
+
if (!R || R.genesis !== R0.genesis || !R.members[me]) {
|
|
130
|
+
log(`watch: ${R0.name} is gone, recreated or archived, or ${me} was removed; stopping`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
const P = R.profile;
|
|
134
|
+
// Lock order throughout planning and dispatch: grant, reader, sessions (then the store's own writes).
|
|
135
|
+
const plan = locked(`grant-${H.name}`, () => readerLock(R, me, () => {
|
|
136
|
+
const { logs, st } = loadRoomUnlocked(R, me);
|
|
137
|
+
if (st.inflight) {
|
|
138
|
+
st.seen = st.inflight.seen;
|
|
139
|
+
st.inflight = undefined;
|
|
140
|
+
saveState(R, me, st);
|
|
141
|
+
}
|
|
142
|
+
const all = link(logs);
|
|
143
|
+
// Heartbeats: one pong per allowed ping, in code; a separate durable cursor, never a model run.
|
|
144
|
+
const heartbeatSeen = { ...st.heartbeatSeen };
|
|
145
|
+
const answered = new Set((logs.get(me) ?? []).filter(m => m.kind === 'pong').map(m => m.reId));
|
|
146
|
+
for (const [id, msgs] of logs) {
|
|
147
|
+
if (id === me)
|
|
148
|
+
continue;
|
|
149
|
+
const at = msgs.findIndex(m => m.id === heartbeatSeen[id]);
|
|
150
|
+
for (const m of msgs.slice(at + 1)) {
|
|
151
|
+
if (m.bad || m.kind !== 'ping' || !m.to.includes(me) || answered.has(m.id) || m.hopEff >= maxHop || (allow && !allow.has(m.from)))
|
|
152
|
+
continue;
|
|
153
|
+
append(R, me, pongText(me, m, 'has a watcher'));
|
|
154
|
+
note(`watch: answered heartbeat ${short(m.id)} from ${m.from}`);
|
|
155
|
+
}
|
|
156
|
+
if (msgs.length)
|
|
157
|
+
heartbeatSeen[id] = msgs.at(-1).id;
|
|
158
|
+
}
|
|
159
|
+
const { msgs: fresh, cursors } = unread(logs, me, st.watched ?? st.seen ?? {}, Infinity, HIDDEN);
|
|
160
|
+
const retry = (st.pending ?? []).flatMap(id => {
|
|
161
|
+
const m = all.get(id);
|
|
162
|
+
if (!m)
|
|
163
|
+
return [];
|
|
164
|
+
const task = taskFor(all, m, P);
|
|
165
|
+
return !task || isOpen(taskOf(all, task, P), P) ? [m] : [];
|
|
166
|
+
});
|
|
167
|
+
const msgs = [...new Map([...retry, ...fresh].map(m => [m.id, m])).values()];
|
|
168
|
+
const go = msgs.filter(m => wakes(m, me, HIDDEN) && m.hopEff < maxHop && (!allow || allow.has(m.from)));
|
|
169
|
+
for (const m of msgs)
|
|
170
|
+
if (!go.includes(m))
|
|
171
|
+
note(`watch: not waking for ${short(m.id)} from ${m.from} (to ${m.to.join(',')}, hop ${m.hopEff})`);
|
|
172
|
+
// Group by the grant of each message's work; a message outside a grant spends the watcher's own budget.
|
|
173
|
+
const groups = new Map();
|
|
174
|
+
for (const m of go) {
|
|
175
|
+
const task = taskFor(all, m, P);
|
|
176
|
+
const g = task ? taskOf(all, task, P).grant : null;
|
|
177
|
+
if (!groups.has(g))
|
|
178
|
+
groups.set(g, { grant: g, msgs: [], task });
|
|
179
|
+
groups.get(g).msgs.push(m);
|
|
180
|
+
}
|
|
181
|
+
const runs = [];
|
|
182
|
+
let hold = false;
|
|
183
|
+
const now = Date.now() / 1000;
|
|
184
|
+
let ownRuns = (st.runs ?? []).filter(t => t > now - span);
|
|
185
|
+
const exhausted = { ...st.exhausted };
|
|
186
|
+
for (const grp of groups.values()) {
|
|
187
|
+
if (!grp.grant) {
|
|
188
|
+
if (w.requireGrant) {
|
|
189
|
+
for (const m of grp.msgs)
|
|
190
|
+
note(`watch: ${short(m.id)} is outside any grant; --require-grant: not starting a turn`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (ownRuns.length >= maxRuns) {
|
|
194
|
+
hold = true;
|
|
195
|
+
note(`watch: own run budget spent (${maxRuns} per ${span}s); ${grp.msgs.length} message(s) wait`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
ownRuns = [...ownRuns, now];
|
|
199
|
+
runs.push({ ...grp, timeout: null });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// The grant lock covers reservation and decisions on this machine.
|
|
203
|
+
const r = (() => {
|
|
204
|
+
// Fence: only the watcher that holds the seat reserves.
|
|
205
|
+
if (readSession(handle)?.pid !== process.pid)
|
|
206
|
+
return { why: 'this watcher no longer holds the seat', lost: true };
|
|
207
|
+
const gr = D.grantOf(all, R.authorityAt, grp.grant);
|
|
208
|
+
if (!gr)
|
|
209
|
+
return { why: `grant ${short(grp.grant)} is not an approved decision with a budget` };
|
|
210
|
+
if (gr.revoked)
|
|
211
|
+
return { why: `grant ${short(gr.id)} was revoked`, exhausted: true, gr };
|
|
212
|
+
const u = D.usageOf(all, gr);
|
|
213
|
+
if (u.left <= 0)
|
|
214
|
+
return { why: `grant ${short(gr.id)} is spent: ${u.runs}/${gr.budget.runs} runs`, exhausted: true, gr };
|
|
215
|
+
if (u.open >= gr.budget.concurrency)
|
|
216
|
+
return { why: `grant ${short(gr.id)} has ${u.open} run(s) open, concurrency ${gr.budget.concurrency}`, wait: true };
|
|
217
|
+
const run = `${raw}.${process.pid}.${Date.now().toString(36)}.${runSeq++}`;
|
|
218
|
+
append(R, me, compose(`usage: reserve a run of ${me}`, [['To', OPERATOR], ['Kind', 'usage'], ['Grant', gr.id], ['Usage', 'reserve'], ['Run', run]]));
|
|
219
|
+
return { gr, run };
|
|
220
|
+
})();
|
|
221
|
+
if (r.lost)
|
|
222
|
+
return { runs: [], hold: true, lost: true };
|
|
223
|
+
if (r.wait) {
|
|
224
|
+
hold = true;
|
|
225
|
+
note(`watch: ${r.why}; waiting`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (r.why) {
|
|
229
|
+
note(`watch: ${r.why}`);
|
|
230
|
+
const key = `${grp.task?.id}/${grp.grant}`;
|
|
231
|
+
if (r.exhausted && grp.task && !exhausted[key]) {
|
|
232
|
+
exhausted[key] = true;
|
|
233
|
+
const t = taskOf(all, grp.task, P);
|
|
234
|
+
const last = grp.msgs.at(-1);
|
|
235
|
+
// The profile names its waiting state, if it has one; otherwise the answer carries no state.
|
|
236
|
+
if (isOpen(t, P))
|
|
237
|
+
append(R, me, compose(`${r.why}; waiting for a new grant`, [['To', [...new Set([t.requester, OPERATOR])].join(', ')], ['Re', last.id], ['State', P.waiting], ['Hop', String(last.hopEff + 1)]]));
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
runs.push({ ...grp, run: r.run, timeout: r.gr.budget['run-seconds'] });
|
|
242
|
+
}
|
|
243
|
+
const pending = [...new Set([...retry.map(m => m.id), ...runs.flatMap(r => r.msgs.map(m => m.id))])];
|
|
244
|
+
saveState(R, me, { ...loadState(R, me), heartbeatSeen, exhausted, pending, runs: ownRuns,
|
|
245
|
+
...(hold ? {} : { watched: cursors }) });
|
|
246
|
+
return { runs, hold, lost: false };
|
|
247
|
+
}));
|
|
248
|
+
if (plan.lost) {
|
|
249
|
+
log(`watch: the seat of ${me} was taken over; stopping`);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
let exitCode = 0;
|
|
253
|
+
const settle = (run, seconds, exit, why) => append(R, me, compose(`usage: settle a run of ${me}${why ? ` (${why})` : ''}`, [['To', OPERATOR], ['Kind', 'usage'], ['Grant', run.grant], ['Usage', 'settle'], ['Run', run.run], ['Seconds', String(seconds)], ['Exit', String(exit)]]));
|
|
254
|
+
for (const run of plan.runs) {
|
|
255
|
+
// Spawn under the same locks as revocation and stop; wait for the turn after releasing them.
|
|
256
|
+
const next = locked(`grant-${H.name}`, () => {
|
|
257
|
+
if (run.grant) {
|
|
258
|
+
const { logs } = readerLock(R, me, () => loadRoomUnlocked(R, me));
|
|
259
|
+
const gr = D.grantOf(link(logs), R.authorityAt, run.grant);
|
|
260
|
+
if (!gr || gr.revoked)
|
|
261
|
+
return { why: !gr ? 'its grant is no longer valid' : 'its grant was revoked' };
|
|
262
|
+
}
|
|
263
|
+
readerLock(R, me, () => {
|
|
264
|
+
const st = loadState(R, me);
|
|
265
|
+
saveState(R, me, { ...st, inflight: { seen: st.seen ?? {} } });
|
|
266
|
+
});
|
|
267
|
+
return locked('sessions', () => {
|
|
268
|
+
const cur = readSession(handle);
|
|
269
|
+
if (!cur || cur.pid !== process.pid)
|
|
270
|
+
return { why: 'the seat was taken over' };
|
|
271
|
+
if (cur.state !== 'active')
|
|
272
|
+
return { why: 'the watcher was stopped' };
|
|
273
|
+
note(`watch: ${run.msgs.length} new${run.grant ? ` under grant ${short(run.grant)}` : ''}: ${cmd}`);
|
|
274
|
+
return { done: turn(cmd, run.timeout, { ...process.env, RINGI_HOME: HOME, RINGI_SESSION: raw, RINGI_MSGS: run.msgs.map(m => m.id).join('\n'), RINGI_GRANT: run.grant ?? '' }) };
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
if (next.why) {
|
|
278
|
+
note(`watch: not starting the next turn: ${next.why}`);
|
|
279
|
+
if (run.run)
|
|
280
|
+
settle(run, 0, 125, `not started: ${next.why}`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const r = await next.done;
|
|
284
|
+
note(`watch: exit ${r.code ?? r.signal}${r.timedOut ? ' (stopped at the grant\'s run-seconds)' : ''} after ${r.seconds}s`);
|
|
285
|
+
// Settled only once the whole turn, descendants included, has ended.
|
|
286
|
+
if (run.run)
|
|
287
|
+
settle(run, r.seconds, r.code ?? 128);
|
|
288
|
+
readerLock(R, me, () => {
|
|
289
|
+
const st = loadState(R, me), delivered = new Set(run.msgs.map(m => m.id));
|
|
290
|
+
// A deadline is an intentional budget stop, not a delivery failure to spend another run on.
|
|
291
|
+
const deliveredTurn = r.code === 0 || r.timedOut;
|
|
292
|
+
saveState(R, me, { ...st, inflight: undefined, ...(deliveredTurn
|
|
293
|
+
? { pending: (st.pending ?? []).filter(id => !delivered.has(id)) }
|
|
294
|
+
: { seen: st.inflight?.seen ?? st.seen }) });
|
|
295
|
+
});
|
|
296
|
+
if (r.code !== 0 || r.timedOut)
|
|
297
|
+
exitCode ||= r.code || 128;
|
|
298
|
+
}
|
|
299
|
+
if (w.once)
|
|
300
|
+
return exitCode || (plan.hold ? 4 : 0);
|
|
301
|
+
await sleep(interval);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// git-store: the Store port on git. The store directory holds one bare repository (`store/.git`):
|
|
2
|
+
// refs/heads/main the shared contract, one commit per amendment
|
|
3
|
+
// refs/heads/<room>/control the room's control record; its first commit is the room's genesis
|
|
4
|
+
// refs/heads/<room>/log/<agent> one log per member; each message is a commit
|
|
5
|
+
// refs/tags/ringi/<genesis>/<agent>/<message>.<signal> signals, annotated tags in their author's namespace
|
|
6
|
+
// refs/archive/<room>@<genesis8>/… an archived room, read-only
|
|
7
|
+
// refs/ringi/accepted/<genesis>/<reader>/<log> readers' pins of accepted heads
|
|
8
|
+
// A signed hub signs every commit and tag with its author's key and verifies what it fetches (remote.ts).
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { cannotWrite, mkdirIn, SANDBOX } from '../../lib/index.js';
|
|
14
|
+
import * as Remote from './remote.js';
|
|
15
|
+
import { fail, note } from '../../lib/index.js';
|
|
16
|
+
import { TEXT_MAX, checkOutgoing, parse } from '../../protocol/index.js';
|
|
17
|
+
import { checkCollisions, checkPath } from '../../protocol/files.js';
|
|
18
|
+
const ZERO = '0'.repeat(40);
|
|
19
|
+
const ident = (id) => ({ GIT_AUTHOR_NAME: id, GIT_AUTHOR_EMAIL: `${id}@ringi`, GIT_COMMITTER_NAME: id, GIT_COMMITTER_EMAIL: `${id}@ringi` });
|
|
20
|
+
export const runGit = (args, o = {}) => {
|
|
21
|
+
const r = spawnSync('git', args, { input: o.input, encoding: 'utf8', env: Remote.gitEnv(o.env), maxBuffer: 1 << 30, timeout: Remote.gitTimeout() });
|
|
22
|
+
return check(args, r, o.where).stdout;
|
|
23
|
+
};
|
|
24
|
+
const runGitOk = (args, o = {}) => {
|
|
25
|
+
const r = spawnSync('git', args, { input: o.input, encoding: 'utf8', env: Remote.gitEnv(o.env), maxBuffer: 1 << 30, timeout: Remote.gitTimeout() });
|
|
26
|
+
Remote.checkGitTimeout(r.error);
|
|
27
|
+
return r;
|
|
28
|
+
};
|
|
29
|
+
const runGitRaw = (args, where) => {
|
|
30
|
+
const r = spawnSync('git', args, { encoding: 'buffer', env: Remote.gitEnv(), maxBuffer: 1 << 30, timeout: Remote.gitTimeout() });
|
|
31
|
+
return check(args, r, where).stdout;
|
|
32
|
+
};
|
|
33
|
+
function check(args, r, where = 'the hub store') {
|
|
34
|
+
Remote.checkGitTimeout(r.error);
|
|
35
|
+
if (r.error)
|
|
36
|
+
throw r.error;
|
|
37
|
+
if (r.status !== 0) {
|
|
38
|
+
const err = String(r.stderr ?? '').trim();
|
|
39
|
+
if (SANDBOX.test(err))
|
|
40
|
+
cannotWrite(where, err.split('\n').at(-1));
|
|
41
|
+
fail(`git ${args.find(a => !a.startsWith('-') && !a.includes('/')) ?? ''}: ${err.split('\n').at(-1)}`);
|
|
42
|
+
}
|
|
43
|
+
return r;
|
|
44
|
+
}
|
|
45
|
+
const splitBuf = (b, sep) => {
|
|
46
|
+
const parts = [];
|
|
47
|
+
let at = 0;
|
|
48
|
+
for (let i = b.indexOf(sep); i >= 0; i = b.indexOf(sep, at)) {
|
|
49
|
+
parts.push(b.subarray(at, i));
|
|
50
|
+
at = i + 1;
|
|
51
|
+
}
|
|
52
|
+
parts.push(b.subarray(at));
|
|
53
|
+
return parts;
|
|
54
|
+
};
|
|
55
|
+
const REGULAR = new Set(['100644', '100755']);
|
|
56
|
+
export class GitStore {
|
|
57
|
+
type = 'git';
|
|
58
|
+
trust;
|
|
59
|
+
dir;
|
|
60
|
+
d;
|
|
61
|
+
home;
|
|
62
|
+
// The handle remote.ts works on: the repository and what it learned on the last sync.
|
|
63
|
+
h;
|
|
64
|
+
constructor(hubDir, d, home) {
|
|
65
|
+
this.d = d;
|
|
66
|
+
this.home = home;
|
|
67
|
+
this.trust = d.trust;
|
|
68
|
+
this.dir = path.join(hubDir, d.store.path, '.git');
|
|
69
|
+
this.h = { name: d.name, dir: this.dir, d };
|
|
70
|
+
if (!fs.existsSync(this.dir))
|
|
71
|
+
fail(`hub ${d.name}: no git store at ${this.dir}`);
|
|
72
|
+
}
|
|
73
|
+
args(a) { return ['--git-dir', this.dir, ...a]; }
|
|
74
|
+
git(a, o) { return runGit(this.args(a), { ...o, where: this.home }); }
|
|
75
|
+
gitOk(a, o) { return runGitOk(this.args(a), o); }
|
|
76
|
+
rev(ref) {
|
|
77
|
+
const r = this.gitOk(['rev-parse', '-q', '--verify', `${ref}^{commit}`]);
|
|
78
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
79
|
+
}
|
|
80
|
+
isAncestor(a, b) { return this.gitOk(['merge-base', '--is-ancestor', a, b]).status === 0; }
|
|
81
|
+
isRef(s) { return /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(s ?? ''); }
|
|
82
|
+
extends(older, newer) { return older === newer || this.isAncestor(older, newer); }
|
|
83
|
+
// --- writing ---
|
|
84
|
+
emptyTree() { return this.git(['hash-object', '-w', '-t', 'tree', '--stdin'], { input: '' }).trim(); }
|
|
85
|
+
// A tree from a base tree with entries put on top; nested paths are fine.
|
|
86
|
+
tree(base, entries) {
|
|
87
|
+
const idx = path.join(this.home, 'tmp', `index.${process.pid}.${crypto.randomBytes(4).toString('hex')}`);
|
|
88
|
+
mkdirIn(this.home, path.dirname(idx));
|
|
89
|
+
const env = { GIT_INDEX_FILE: idx };
|
|
90
|
+
try {
|
|
91
|
+
this.git(['read-tree', ...(base ? [base] : ['--empty'])], { env });
|
|
92
|
+
for (const e of entries) {
|
|
93
|
+
const sha = this.git(['hash-object', '-w', ...(e.file ? ['--', e.file] : ['--stdin'])], { input: e.content }).trim();
|
|
94
|
+
this.git(['update-index', '--add', '--cacheinfo', `${e.mode},${sha},${e.path}`], { env });
|
|
95
|
+
}
|
|
96
|
+
return this.git(['write-tree'], { env }).trim();
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
fs.rmSync(idx, { force: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Every commit is made here: on a signed hub, signed by its author's key on this machine.
|
|
103
|
+
commit(who, text, tree, parents) {
|
|
104
|
+
return this.git(['commit-tree', ...Remote.sign(this.h, who, this.home), tree, ...parents.flatMap(p => ['-p', p])], { input: text, env: ident(who) }).trim();
|
|
105
|
+
}
|
|
106
|
+
advance(ref, sha, old) {
|
|
107
|
+
if (this.gitOk(['update-ref', ref, sha, old ?? ZERO]).status !== 0)
|
|
108
|
+
return false;
|
|
109
|
+
// A signed hub publishes at once; a rejected push keeps the commit here and alarms (remote.ts).
|
|
110
|
+
Remote.push(this.h, ref);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
static create(hubDir, d, home, contract, text) {
|
|
114
|
+
const dir = path.join(hubDir, d.store.path, '.git');
|
|
115
|
+
// A signed hub's repository already exists: rootFor made it with the root key.
|
|
116
|
+
if (!fs.existsSync(dir)) {
|
|
117
|
+
mkdirIn(home, path.dirname(dir));
|
|
118
|
+
runGit(['init', '--bare', '-q', '-b', 'main', dir], { where: home });
|
|
119
|
+
}
|
|
120
|
+
const s = new GitStore(hubDir, d, home);
|
|
121
|
+
const sha = s.commit('operator', text, s.tree(null, Object.entries(contract).map(([p, c]) => ({ path: p, content: c, mode: '100644' }))), []);
|
|
122
|
+
if (d.trust === 'signed')
|
|
123
|
+
d.genesis = sha;
|
|
124
|
+
if (!s.advance('refs/heads/main', sha, null))
|
|
125
|
+
fail(`hub ${d.name}: main already exists`);
|
|
126
|
+
return s;
|
|
127
|
+
}
|
|
128
|
+
// A new signed hub: the root key is generated on this machine and pinned in the descriptor.
|
|
129
|
+
static rootFor(hubDir, d, home) {
|
|
130
|
+
const dir = path.join(hubDir, d.store.path, '.git');
|
|
131
|
+
mkdirIn(home, path.dirname(dir));
|
|
132
|
+
runGit(['init', '--bare', '-q', '-b', 'main', dir], { where: home });
|
|
133
|
+
return Remote.setupSigned({ name: d.name, dir, d }, home, d.store.remote).root;
|
|
134
|
+
}
|
|
135
|
+
sync() {
|
|
136
|
+
Remote.sync(this.h);
|
|
137
|
+
}
|
|
138
|
+
// --- the contract ---
|
|
139
|
+
contractHead() { return this.rev('refs/heads/main') ?? fail(`hub ${this.d.name} has no contract (main)`); }
|
|
140
|
+
contractFile(rev, name) {
|
|
141
|
+
const r = this.gitOk(['show', `${rev}:${name}`]);
|
|
142
|
+
return r.status === 0 ? r.stdout : null;
|
|
143
|
+
}
|
|
144
|
+
contractFiles(rev) { return this.git(['ls-tree', '-r', '--name-only', rev]).split('\n').filter(Boolean); }
|
|
145
|
+
amendContract(who, text, files) {
|
|
146
|
+
const head = this.contractHead();
|
|
147
|
+
const sha = this.commit(who, text, this.tree(`${head}^{tree}`, Object.entries(files).map(([p, c]) => ({ path: p, content: c, mode: '100644' }))), [head]);
|
|
148
|
+
if (!this.advance('refs/heads/main', sha, head))
|
|
149
|
+
fail('the contract moved while amending; retry');
|
|
150
|
+
return sha;
|
|
151
|
+
}
|
|
152
|
+
// --- rooms ---
|
|
153
|
+
base(name) { return name.includes('@') ? `refs/archive/${name}` : `refs/heads/${name}`; }
|
|
154
|
+
roomNames(archived) {
|
|
155
|
+
const re = archived ? /^refs\/archive\/([^/]+)\/control$/ : /^refs\/heads\/([^/]+)\/control$/;
|
|
156
|
+
return this.git(['for-each-ref', '--format=%(refname)', archived ? 'refs/archive/' : 'refs/heads/']).split('\n').map(r => re.exec(r)?.[1]).filter((x) => !!x).sort();
|
|
157
|
+
}
|
|
158
|
+
room(name) {
|
|
159
|
+
const control = `${this.base(name)}/control`;
|
|
160
|
+
const head = this.rev(control);
|
|
161
|
+
if (!head)
|
|
162
|
+
return null;
|
|
163
|
+
const genesis = this.git(['rev-list', '--reverse', control, '^refs/heads/main']).split('\n')[0]
|
|
164
|
+
|| this.git(['rev-list', '--reverse', control]).split('\n').filter(Boolean).at(-1);
|
|
165
|
+
const recs = this.git(['log', '--reverse', '--format=%H%x1f%B%x1e', control, `^${genesis}^`]).split('\x1e').map(s => s.trim()).filter(Boolean);
|
|
166
|
+
return { name, archived: name.includes('@'), head, genesis, base: this.git(['rev-parse', `${genesis}^`]).trim(),
|
|
167
|
+
control: recs.map(r => {
|
|
168
|
+
const [rev, text = ''] = r.split('\x1f');
|
|
169
|
+
return { rev: rev.trim(), text };
|
|
170
|
+
}) };
|
|
171
|
+
}
|
|
172
|
+
controlFile(room, name, rev) {
|
|
173
|
+
const r = this.gitOk(['show', `${rev ?? room.head}:${name}`]);
|
|
174
|
+
return r.status === 0 ? r.stdout : null;
|
|
175
|
+
}
|
|
176
|
+
createRoom(name, who, text, files) {
|
|
177
|
+
const ref = `refs/heads/${name}/control`;
|
|
178
|
+
if (this.rev(ref))
|
|
179
|
+
fail(`room ${name} exists in hub ${this.d.name}`);
|
|
180
|
+
const sha = this.commit(who, text, this.tree(null, Object.entries(files).map(([p, c]) => ({ path: p, content: c, mode: '100644' }))), [this.contractHead()]);
|
|
181
|
+
if (!this.advance(ref, sha, null))
|
|
182
|
+
fail(`room ${name} was created meanwhile`);
|
|
183
|
+
return sha;
|
|
184
|
+
}
|
|
185
|
+
// Every other file of the control tree (a signed room's allowed_signers) is kept.
|
|
186
|
+
updateControl(room, who, text, files) {
|
|
187
|
+
const ref = `${this.base(room.name)}/control`;
|
|
188
|
+
const sha = this.commit(who, text, this.tree(`${room.head}^{tree}`, Object.entries(files).map(([p, c]) => ({ path: p, content: c, mode: '100644' }))), [room.head]);
|
|
189
|
+
if (!this.advance(ref, sha, room.head))
|
|
190
|
+
fail(`room ${room.name}: its control moved; retry`);
|
|
191
|
+
return sha;
|
|
192
|
+
}
|
|
193
|
+
refsUnder(prefix) {
|
|
194
|
+
return this.git(['for-each-ref', '--format=%(refname) %(objectname)', prefix]).split('\n').filter(Boolean).map(l => l.split(' '));
|
|
195
|
+
}
|
|
196
|
+
archiveRoom(room, as) {
|
|
197
|
+
const moves = [[`${this.base(room.name)}/`, `refs/archive/${as}/`], [`refs/tags/ringi/${room.genesis}/`, `refs/archive/${as}/signals/`]];
|
|
198
|
+
for (const [from, to] of moves) {
|
|
199
|
+
for (const [ref, sha] of this.refsUnder(from)) {
|
|
200
|
+
this.git(['update-ref', `${to}${ref.slice(from.length)}`, sha, ZERO]);
|
|
201
|
+
this.git(['update-ref', '-d', ref, sha]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
deleteRoom(room) {
|
|
206
|
+
for (const prefix of [`${this.base(room.name)}/`, `refs/ringi/accepted/${room.genesis}/`, `refs/tags/ringi/${room.genesis}/`]) {
|
|
207
|
+
for (const [ref] of this.refsUnder(prefix))
|
|
208
|
+
this.git(['update-ref', '-d', ref]);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
legacy(room) {
|
|
212
|
+
const base = this.base(room.name);
|
|
213
|
+
return { name: room.name, head: room.head, genesis: room.genesis, archived: room.archived, control: `${base}/control`, log: (who) => `${base}/log/${who}` };
|
|
214
|
+
}
|
|
215
|
+
roster(room) {
|
|
216
|
+
if (this.trust !== 'signed' || room.archived)
|
|
217
|
+
return null;
|
|
218
|
+
const v = Remote.verifyRoom(this.h, this.legacy(room));
|
|
219
|
+
return { members: v.members, signers: v.entries };
|
|
220
|
+
}
|
|
221
|
+
// --- logs ---
|
|
222
|
+
logIds(room) {
|
|
223
|
+
const prefix = `${this.base(room.name)}/log/`;
|
|
224
|
+
return this.git(['for-each-ref', '--format=%(refname)', prefix]).split('\n').filter(Boolean).map(r => r.slice(prefix.length));
|
|
225
|
+
}
|
|
226
|
+
logHead(room, id) { return this.rev(`${this.base(room.name)}/log/${id}`); }
|
|
227
|
+
// The log's commits after the genesis, oldest first, read as bytes: the text is the commit message, the files are
|
|
228
|
+
// the explicit Ringi-Files list, or the changed paths for records without it.
|
|
229
|
+
messages(room, id, head) {
|
|
230
|
+
const raw = runGitRaw(this.args(['log', '--topo-order', '--reverse', '--no-renames', '--raw', '--no-abbrev', '--format=%x1e%H%x1f%P%x1f%ct%x1f%B%x1f', head, `^${room.genesis}`]), this.home);
|
|
231
|
+
return splitBuf(raw, 0x1e).filter(b => b.length).map(rec => {
|
|
232
|
+
const [sha, parent, ct, body, rest = Buffer.alloc(0)] = splitBuf(rec, 0x1f);
|
|
233
|
+
let files = rest.toString('utf8').split('\n').map(l => /^:\d{6} (\d{6}) \S+ \S+ ([A-Z])\d*\t(.*)$/.exec(l)).filter((x) => !!x)
|
|
234
|
+
.filter(([, , status]) => status !== 'D').map(([, mode, , p]) => ({ path: p, mode }));
|
|
235
|
+
const declared = parse(body.toString('utf8'), s => this.isRef(s)).trailers['ringi-files'];
|
|
236
|
+
const refused = [];
|
|
237
|
+
if (declared) {
|
|
238
|
+
let names = [];
|
|
239
|
+
try {
|
|
240
|
+
const paths = JSON.parse(declared[0]);
|
|
241
|
+
if (declared.length !== 1 || !Array.isArray(paths) || !paths.every(p => typeof p === 'string'))
|
|
242
|
+
fail('invalid attachment list');
|
|
243
|
+
names = paths.map(checkPath);
|
|
244
|
+
if (new Set(names).size !== names.length)
|
|
245
|
+
fail('duplicate attachment path');
|
|
246
|
+
checkCollisions(names);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
refused.push({ path: 'Ringi-Files', mode: 'invalid', why: 'expected one JSON array of safe, distinct file paths' });
|
|
250
|
+
}
|
|
251
|
+
const wanted = new Set(names);
|
|
252
|
+
files = names.length && !refused.length ? splitBuf(runGitRaw(this.args(['ls-tree', '-r', '-z', sha.toString()]), this.home), 0)
|
|
253
|
+
.map(b => /^(\d+) \w+ [0-9a-f]+\t(.*)$/s.exec(b.toString('utf8'))).filter((x) => !!x)
|
|
254
|
+
.map(([, mode, p]) => ({ path: p, mode })).filter(f => wanted.has(f.path)) : [];
|
|
255
|
+
for (const f of files)
|
|
256
|
+
wanted.delete(f.path);
|
|
257
|
+
if (!refused.length)
|
|
258
|
+
for (const p of wanted)
|
|
259
|
+
files.push({ path: p, mode: 'missing' });
|
|
260
|
+
}
|
|
261
|
+
return { id: sha.toString(), parent: parent.toString() || null, time: Number(ct.toString()), text: body,
|
|
262
|
+
files: files.filter(f => REGULAR.has(f.mode)), refused: [...refused, ...files.filter(f => !REGULAR.has(f.mode))] };
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// One commit on the author's log, compare-and-swap, so two writers of one log retry instead of forking it.
|
|
266
|
+
append(room, who, text, entries) {
|
|
267
|
+
if (room.archived)
|
|
268
|
+
fail(`room ${room.name} is archived: read-only`);
|
|
269
|
+
if (checkOutgoing(text, s => this.isRef(s)).trailers['ringi-files'])
|
|
270
|
+
fail('Ringi-Files is store metadata; supply attachments instead');
|
|
271
|
+
if (entries.length)
|
|
272
|
+
text = `${text.trimEnd()}\nRingi-Files: ${JSON.stringify([...new Set(entries.map(e => e.path))])}\n`;
|
|
273
|
+
if (Buffer.byteLength(text) > TEXT_MAX)
|
|
274
|
+
fail('attachment list makes the message exceed 64 KiB; use fewer or shorter paths');
|
|
275
|
+
checkOutgoing(text, s => this.isRef(s));
|
|
276
|
+
const ref = `${this.base(room.name)}/log/${who}`;
|
|
277
|
+
for (let i = 0; i < 8; i++) {
|
|
278
|
+
const head = this.rev(ref);
|
|
279
|
+
const base = head ? `${head}^{tree}` : null;
|
|
280
|
+
const tree = entries.length ? this.tree(base, entries) : base ? this.git(['rev-parse', base]).trim() : this.emptyTree();
|
|
281
|
+
const sha = this.commit(who, text, tree, [head ?? room.genesis]);
|
|
282
|
+
if (this.advance(ref, sha, head))
|
|
283
|
+
return sha;
|
|
284
|
+
}
|
|
285
|
+
return fail(`the log ${who} kept moving while writing; retry`);
|
|
286
|
+
}
|
|
287
|
+
verifyLog(room, id, head) {
|
|
288
|
+
return this.trust === 'signed' ? Remote.verify(this.h, this.legacy(room), id, head) : null;
|
|
289
|
+
}
|
|
290
|
+
pin(room, reader, id, rev) {
|
|
291
|
+
if (!room.archived)
|
|
292
|
+
this.git(['update-ref', `refs/ringi/accepted/${room.genesis}/${reader}/${id}`, rev]);
|
|
293
|
+
}
|
|
294
|
+
unpin(room, reader, id) {
|
|
295
|
+
this.gitOk(['update-ref', '-d', `refs/ringi/accepted/${room.genesis}/${reader}/${id}`]);
|
|
296
|
+
}
|
|
297
|
+
file(rev, p) { return runGitRaw(this.args(['show', `${rev}:${p}`]), this.home); }
|
|
298
|
+
sizes(rev, paths) {
|
|
299
|
+
if (!paths.length)
|
|
300
|
+
return new Map();
|
|
301
|
+
const wanted = new Set(paths);
|
|
302
|
+
return new Map(splitBuf(runGitRaw(this.args(['ls-tree', '-r', '-l', '-z', rev]), this.home), 0)
|
|
303
|
+
.map(b => /^\d+ blob [0-9a-f]+ +(\d+)\t(.*)$/s.exec(b.toString('utf8'))).filter((x) => !!x)
|
|
304
|
+
.filter(([, , p]) => wanted.has(p)).map(([, n, p]) => [p, Number(n)]));
|
|
305
|
+
}
|
|
306
|
+
// --- signals ---
|
|
307
|
+
// An annotated tag in its author's namespace under the room's genesis (a room recreated under the same name is
|
|
308
|
+
// another namespace), signed on a signed hub. Writing the same signal twice is a no-op.
|
|
309
|
+
signal(room, who, target, name, text) {
|
|
310
|
+
const tag = `ringi/${room.genesis}/${who}/${name}`;
|
|
311
|
+
const ref = `refs/tags/${tag}`;
|
|
312
|
+
if (this.gitOk(['rev-parse', '-q', '--verify', ref]).status === 0) {
|
|
313
|
+
Remote.push(this.h, ref);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const sign = Remote.sign(this.h, who, this.home);
|
|
317
|
+
const key = sign[0]?.slice(2);
|
|
318
|
+
this.git([...(key ? ['-c', `user.signingkey=${key}`] : []), 'tag', key ? '-s' : '-a', '-F', '-', tag, target], { input: text, env: ident(who) });
|
|
319
|
+
Remote.push(this.h, ref);
|
|
320
|
+
}
|
|
321
|
+
signals(room) {
|
|
322
|
+
const prefix = room.archived ? `refs/archive/${room.name}/signals/` : `refs/tags/ringi/${room.genesis}/`;
|
|
323
|
+
const raw = this.git(['for-each-ref', '--format=%(refname)%1f%(objectname)%1f%(objecttype)%1f%(*objectname)%1f%(taggername)%1f%(contents)%1e', prefix]);
|
|
324
|
+
const records = raw.split('\x1e').map(s => s.replace(/^\n/, '')).filter(Boolean).map(rec => {
|
|
325
|
+
const [ref, object, type, target, tagger, text = ''] = rec.split('\x1f');
|
|
326
|
+
const [actor, ...rest] = ref.slice(prefix.length).split('/');
|
|
327
|
+
// Only an annotated tag on a commit is a signal; anything else points nowhere.
|
|
328
|
+
return { ref, object, name: rest.join('/'), actor, target: type === 'tag' ? target : '', tagger, text };
|
|
329
|
+
});
|
|
330
|
+
if (this.trust !== 'signed' || room.archived)
|
|
331
|
+
return records;
|
|
332
|
+
return Remote.withVerification(this.h, () => records.filter(s => {
|
|
333
|
+
const why = Remote.verifySignal(this.h, this.legacy(room), s.ref, s.object);
|
|
334
|
+
if (why)
|
|
335
|
+
note(`ALARM signal ${s.ref}: ${why}; quarantined and withheld`);
|
|
336
|
+
return !why;
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
// --- signed stores ---
|
|
340
|
+
admit(room, who, key) { return Remote.roomAdmit(this.h, this.legacy(room), who, key, this.home); }
|
|
341
|
+
joinKey(room, who) { return Remote.join(this.h, this.legacy(room), who, this.home); }
|
|
342
|
+
forgetLog(room, id) { Remote.forget(this.h, this.legacy(room), id); }
|
|
343
|
+
rootKey() { return this.d.root ?? fail(`hub ${this.d.name} has no pinned root`); }
|
|
344
|
+
rootRoster() { return Remote.rootRoster(this.h); }
|
|
345
|
+
}
|