@idleware/ringi 0.1.1 → 0.2.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/package.json +1 -1
- package/src/main/resources/skill/ringi/SKILL.md +11 -1
- package/target/dts/app/rooms.d.ts +1 -0
- package/target/dts/domain/signals.d.ts +1 -1
- package/target/dts/protocol/index.d.ts +1 -0
- package/target/dts/store/git/index.d.ts +19 -5
- package/target/dts/store/git/remote.d.ts +20 -4
- package/target/dts/store/index.d.ts +4 -1
- package/target/esm/app/rooms.js +8 -1
- package/target/esm/app/session.js +3 -3
- package/target/esm/cli/index.js +1 -0
- package/target/esm/cli/operator.js +4 -0
- package/target/esm/domain/signals.js +21 -16
- package/target/esm/hub/signals.js +5 -5
- package/target/esm/protocol/index.js +2 -0
- package/target/esm/runtime/reader.js +3 -2
- package/target/esm/store/git/index.js +375 -85
- package/target/esm/store/git/remote.js +233 -175
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// git-store: the Store port on git. The store directory holds one bare repository (`store/.git`):
|
|
2
2
|
// refs/heads/main the shared contract, one commit per amendment
|
|
3
|
-
// refs/heads/<room
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// refs/
|
|
7
|
-
// refs/
|
|
3
|
+
// refs/heads/<room> the room: one linear branch, like a chat. Its first commit is the room's genesis
|
|
4
|
+
// (the root's, on top of main); then every message is a commit by its author, and
|
|
5
|
+
// every control record (Kind: room) a commit by the root
|
|
6
|
+
// refs/tags/<agent>.<signal>.<message12> signals: annotated tags without text on the message's commit
|
|
7
|
+
// refs/archive/<room>@<genesis8> an archived room, read-only
|
|
8
|
+
// refs/ringi/accepted/<genesis>/<reader>/<author> readers' pins of each author's accepted last message
|
|
9
|
+
// A member's log is its own commits in the room's branch, in branch order. The control files at the root of a room's
|
|
10
|
+
// tree (members, profile, signers) change only in the root's control records.
|
|
8
11
|
// A signed hub signs every commit and tag with its author's key and verifies what it fetches (remote.ts).
|
|
9
12
|
import fs from 'node:fs';
|
|
10
13
|
import path from 'node:path';
|
|
@@ -12,8 +15,8 @@ import crypto from 'node:crypto';
|
|
|
12
15
|
import { spawnSync } from 'node:child_process';
|
|
13
16
|
import { cannotWrite, mkdirIn, SANDBOX } from '../../lib/index.js';
|
|
14
17
|
import * as Remote from './remote.js';
|
|
15
|
-
import { fail, note } from '../../lib/index.js';
|
|
16
|
-
import { TEXT_MAX, checkOutgoing, parse } from '../../protocol/index.js';
|
|
18
|
+
import { fail, locked, note, strictJson, writeJsonAtomic } from '../../lib/index.js';
|
|
19
|
+
import { AGENT_RE, ID_RE, TEXT_MAX, checkOutgoing, compose, parse } from '../../protocol/index.js';
|
|
17
20
|
import { checkCollisions, checkPath } from '../../protocol/files.js';
|
|
18
21
|
const ZERO = '0'.repeat(40);
|
|
19
22
|
const ident = (id) => ({ GIT_AUTHOR_NAME: id, GIT_AUTHOR_EMAIL: `${id}@ringi`, GIT_COMMITTER_NAME: id, GIT_COMMITTER_EMAIL: `${id}@ringi` });
|
|
@@ -26,8 +29,8 @@ const runGitOk = (args, o = {}) => {
|
|
|
26
29
|
Remote.checkGitTimeout(r.error);
|
|
27
30
|
return r;
|
|
28
31
|
};
|
|
29
|
-
const runGitRaw = (args, where) => {
|
|
30
|
-
const r = spawnSync('git', args, { encoding: 'buffer', env: Remote.gitEnv(), maxBuffer: 1 << 30, timeout: Remote.gitTimeout() });
|
|
32
|
+
const runGitRaw = (args, where, input) => {
|
|
33
|
+
const r = spawnSync('git', args, { input, encoding: 'buffer', env: Remote.gitEnv(), maxBuffer: 1 << 30, timeout: Remote.gitTimeout() });
|
|
31
34
|
return check(args, r, where).stdout;
|
|
32
35
|
};
|
|
33
36
|
function check(args, r, where = 'the hub store') {
|
|
@@ -53,6 +56,9 @@ const splitBuf = (b, sep) => {
|
|
|
53
56
|
return parts;
|
|
54
57
|
};
|
|
55
58
|
const REGULAR = new Set(['100644', '100755']);
|
|
59
|
+
// The root's principal, and the files only its control records may change.
|
|
60
|
+
const ROOT = 'operator';
|
|
61
|
+
const CONTROL_FILES = new Set(['ROOM.md', 'members.json', 'profile.json', 'allowed_signers']);
|
|
56
62
|
export class GitStore {
|
|
57
63
|
type = 'git';
|
|
58
64
|
trust;
|
|
@@ -81,14 +87,15 @@ export class GitStore {
|
|
|
81
87
|
isRef(s) { return /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(s ?? ''); }
|
|
82
88
|
extends(older, newer) { return older === newer || this.isAncestor(older, newer); }
|
|
83
89
|
// --- writing ---
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
tree(base, entries) {
|
|
90
|
+
// A tree from a base tree without the `drop` paths, with entries put on top; nested paths are fine.
|
|
91
|
+
tree(base, entries, drop = []) {
|
|
87
92
|
const idx = path.join(this.home, 'tmp', `index.${process.pid}.${crypto.randomBytes(4).toString('hex')}`);
|
|
88
93
|
mkdirIn(this.home, path.dirname(idx));
|
|
89
94
|
const env = { GIT_INDEX_FILE: idx };
|
|
90
95
|
try {
|
|
91
96
|
this.git(['read-tree', ...(base ? [base] : ['--empty'])], { env });
|
|
97
|
+
if (drop.length)
|
|
98
|
+
this.git(['update-index', '--index-info'], { env, input: drop.map(p => `0 ${ZERO}\t${p}\n`).join('') });
|
|
92
99
|
for (const e of entries) {
|
|
93
100
|
const sha = this.git(['hash-object', '-w', ...(e.file ? ['--', e.file] : ['--stdin'])], { input: e.content }).trim();
|
|
94
101
|
this.git(['update-index', '--add', '--cacheinfo', `${e.mode},${sha},${e.path}`], { env });
|
|
@@ -107,8 +114,7 @@ export class GitStore {
|
|
|
107
114
|
if (this.gitOk(['update-ref', ref, sha, old ?? ZERO]).status !== 0)
|
|
108
115
|
return false;
|
|
109
116
|
// 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;
|
|
117
|
+
return Remote.push(this.h, ref, { behind: !!old && ref !== 'refs/heads/main' });
|
|
112
118
|
}
|
|
113
119
|
static create(hubDir, d, home, contract, text) {
|
|
114
120
|
const dir = path.join(hubDir, d.store.path, '.git');
|
|
@@ -150,31 +156,72 @@ export class GitStore {
|
|
|
150
156
|
return sha;
|
|
151
157
|
}
|
|
152
158
|
// --- rooms ---
|
|
153
|
-
|
|
159
|
+
ref(name) { return name.includes('@') ? `refs/archive/${name}` : `refs/heads/${name}`; }
|
|
160
|
+
transition(name) { return `refs/ringi/migrating/${name}`; }
|
|
161
|
+
tip(name) { return this.rev(this.ref(name)) ?? this.rev(this.transition(name)); }
|
|
162
|
+
// Rooms in ringi 0.1's layout (`<room>/control` and a branch per agent) are listed, and refused when opened.
|
|
154
163
|
roomNames(archived) {
|
|
155
|
-
const
|
|
156
|
-
|
|
164
|
+
const prefix = archived ? 'refs/archive/' : 'refs/heads/';
|
|
165
|
+
const names = this.git(['for-each-ref', '--format=%(refname)', prefix]).split('\n').filter(Boolean).map(r => r.slice(prefix.length))
|
|
166
|
+
.map(n => n.replace(/\/control$/, '')).filter(n => !n.includes('/') && n !== 'main' && n.includes('@') === archived);
|
|
167
|
+
return [...new Set(names)].sort();
|
|
168
|
+
}
|
|
169
|
+
// A control record: the room's genesis, or a root commit of Kind: room.
|
|
170
|
+
isControl(room, sha, author, text) {
|
|
171
|
+
return sha === room.genesis || (author === ROOT && parse(text, x => this.isRef(x)).kind === 'room');
|
|
172
|
+
}
|
|
173
|
+
// The room's branch, oldest first, from its genesis. The last scan of each room is kept and grown by what the tip
|
|
174
|
+
// added on top of it.
|
|
175
|
+
scanned = new Map();
|
|
176
|
+
entries(room, tip) {
|
|
177
|
+
const last = this.scanned.get(room.genesis);
|
|
178
|
+
if (last?.tip === tip)
|
|
179
|
+
return last.entries;
|
|
180
|
+
const added = last ? this.scan(room, tip, last.tip) : [];
|
|
181
|
+
const entries = last && added.length && added[0].parent === last.tip ? [...last.entries, ...added] : this.scan(room, tip);
|
|
182
|
+
const judged = this.judged(room, tip, entries);
|
|
183
|
+
this.scanned.set(room.genesis, { tip, entries: judged });
|
|
184
|
+
return judged;
|
|
185
|
+
}
|
|
186
|
+
// On a signed hub the branch's verification says which commits are control records, and why the others that are no
|
|
187
|
+
// valid message are skipped; a commit off the branch's first parents is never a message (remote.ts).
|
|
188
|
+
judged(room, tip, entries) {
|
|
189
|
+
if (this.trust !== 'signed' || room.archived)
|
|
190
|
+
return entries;
|
|
191
|
+
const b = Remote.branch(this.h, { name: room.name, ref: this.ref(room.name), genesis: room.genesis }, tip);
|
|
192
|
+
const control = new Set(b.control);
|
|
193
|
+
return entries.map(e => ({ ...e, control: control.has(e.sha),
|
|
194
|
+
skipped: control.has(e.sha) || b.authors.has(e.sha) ? undefined : b.skipped.get(e.sha) ?? 'not on the room\'s branch' }));
|
|
195
|
+
}
|
|
196
|
+
scan(room, tip, after) {
|
|
197
|
+
const shas = this.git(['rev-list', '--topo-order', '--reverse', tip, '--not', after ?? `${room.genesis}^@`]).trim().split('\n').filter(Boolean);
|
|
198
|
+
return Remote.readCommits(this.h, shas).map(c => {
|
|
199
|
+
const text = c.body.toString('utf8');
|
|
200
|
+
return { sha: c.sha, parent: c.parents[0], author: c.author, text, control: this.isControl(room, c.sha, c.author, text) };
|
|
201
|
+
});
|
|
157
202
|
}
|
|
158
203
|
room(name) {
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
if (!
|
|
204
|
+
const ref = this.ref(name);
|
|
205
|
+
const tip = this.tip(name);
|
|
206
|
+
if (!tip && this.rev(`${ref}/control`))
|
|
207
|
+
fail(`room ${name} is in the layout of ringi 0.1; run ringi room migrate ${name} --hub ${this.d.name} to keep its history in one branch`);
|
|
208
|
+
if (!tip)
|
|
162
209
|
return null;
|
|
163
|
-
const genesis = this.git(['rev-list', '--reverse',
|
|
164
|
-
|| this.git(['rev-list', '--reverse',
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}) };
|
|
210
|
+
const genesis = this.git(['rev-list', '--first-parent', '--reverse', tip, '^refs/heads/main']).split('\n')[0]
|
|
211
|
+
|| this.git(['rev-list', '--first-parent', '--reverse', tip]).split('\n').filter(Boolean).at(-1);
|
|
212
|
+
const archived = name.includes('@');
|
|
213
|
+
if (this.trust === 'signed' && !archived && this.h.remoteAlarms?.[ref])
|
|
214
|
+
fail(`ALARM ${this.h.remoteAlarms[ref]}; room ${name} is quarantined`);
|
|
215
|
+
const control = this.entries({ name, genesis, archived }, tip).filter(e => e.control).map(e => ({ rev: e.sha, text: e.text }));
|
|
216
|
+
return { name, archived, head: control.at(-1).rev, genesis, tip, base: this.rev(`${genesis}^`) ?? ZERO, control };
|
|
171
217
|
}
|
|
218
|
+
// Control files are read at a control record, never at the branch's tip.
|
|
172
219
|
controlFile(room, name, rev) {
|
|
173
220
|
const r = this.gitOk(['show', `${rev ?? room.head}:${name}`]);
|
|
174
221
|
return r.status === 0 ? r.stdout : null;
|
|
175
222
|
}
|
|
176
223
|
createRoom(name, who, text, files) {
|
|
177
|
-
const ref = `refs/heads/${name}
|
|
224
|
+
const ref = `refs/heads/${name}`;
|
|
178
225
|
if (this.rev(ref))
|
|
179
226
|
fail(`room ${name} exists in hub ${this.d.name}`);
|
|
180
227
|
const sha = this.commit(who, text, this.tree(null, Object.entries(files).map(([p, c]) => ({ path: p, content: c, mode: '100644' }))), [this.contractHead()]);
|
|
@@ -182,54 +229,252 @@ export class GitStore {
|
|
|
182
229
|
fail(`room ${name} was created meanwhile`);
|
|
183
230
|
return sha;
|
|
184
231
|
}
|
|
185
|
-
//
|
|
232
|
+
// Old messages keep their ids and parents. One control record joins the old tips, then all new writes share the
|
|
233
|
+
// room's branch. The transition ref makes the room readable even if the process exits between the two ref
|
|
234
|
+
// transactions (Git cannot create <room> and delete <room>/control in the same transaction).
|
|
235
|
+
migrateRoom(name) {
|
|
236
|
+
if (this.trust !== 'asserted')
|
|
237
|
+
fail('legacy room migration is only supported for local asserted hubs');
|
|
238
|
+
if (!ID_RE.test(name) || name === 'main')
|
|
239
|
+
fail('expected a room id');
|
|
240
|
+
const ref = this.ref(name), pending = this.transition(name);
|
|
241
|
+
const source = this.rev(pending) ?? this.rev(ref) ?? this.rev(`${ref}/control`) ?? fail(`no legacy room ${name}`);
|
|
242
|
+
const genesis = this.git(['rev-list', '--first-parent', '--reverse', source, '^refs/heads/main']).trim().split('\n')[0];
|
|
243
|
+
const readers = path.join(this.home, 'readers', this.d.name, genesis);
|
|
244
|
+
const files = fs.existsSync(readers) ? fs.readdirSync(readers).filter(f => f.endsWith('.json')).sort() : [];
|
|
245
|
+
// Readers pin tag objects while holding these same locks. Keep their state stable across both ref transactions
|
|
246
|
+
// and the explicit conversion of their old accepted objects; no new baseline is supplied.
|
|
247
|
+
const protect = (at) => at === files.length ? migrate() : locked(path.join(this.home, 'locks', `reader-${this.d.name}-${genesis.slice(0, 12)}-${files[at].slice(0, -5)}.lock`), () => protect(at + 1));
|
|
248
|
+
const migrate = () => {
|
|
249
|
+
if (!genesis || this.git(['show', '-s', '--format=%s', genesis]).trim() !== `room ${name}`)
|
|
250
|
+
fail('legacy room genesis does not name this room');
|
|
251
|
+
const finish = (sha) => {
|
|
252
|
+
this.git(['update-ref', '--stdin'], { input: `start\ncreate ${ref} ${sha}\ndelete ${pending} ${sha}\nprepare\ncommit\n` });
|
|
253
|
+
return sha;
|
|
254
|
+
};
|
|
255
|
+
const staged = this.rev(pending);
|
|
256
|
+
const current = this.rev(ref);
|
|
257
|
+
if (staged || current) {
|
|
258
|
+
const tip = staged ?? current;
|
|
259
|
+
const tags = this.migrateSignals(name, genesis, [tip], files.map(f => path.join(readers, f)));
|
|
260
|
+
if (staged)
|
|
261
|
+
finish(staged);
|
|
262
|
+
tags();
|
|
263
|
+
return tip;
|
|
264
|
+
}
|
|
265
|
+
const ctl = `${ref}/control`, head = this.rev(ctl) ?? fail(`no legacy room ${name}`);
|
|
266
|
+
const logs = this.refsUnder(`${ref}/log/`);
|
|
267
|
+
for (const [r, sha] of [[ctl, head], ...logs]) {
|
|
268
|
+
const who = r === ctl ? ROOT : r.slice(`${ref}/log/`.length);
|
|
269
|
+
if (!ID_RE.test(who) || !this.extends(genesis, sha))
|
|
270
|
+
fail(`ALARM ${r} does not descend from its room genesis`);
|
|
271
|
+
const commits = this.git(['rev-list', '--parents', '--reverse', sha, `^${genesis}`]).trim().split('\n').filter(Boolean);
|
|
272
|
+
let parent = genesis;
|
|
273
|
+
for (const row of commits) {
|
|
274
|
+
const [id, ...parents] = row.split(' ');
|
|
275
|
+
if (parents.length !== 1 || parents[0] !== parent)
|
|
276
|
+
fail(`ALARM ${r} is not linear`);
|
|
277
|
+
if (this.git(['show', '-s', '--format=%an', id]).trim() !== who)
|
|
278
|
+
fail(`ALARM ${r} contains another author's message`);
|
|
279
|
+
parent = id;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Accepted heads must still be present before any ref moves. Migration never supplies a fresh baseline.
|
|
283
|
+
const byAuthor = new Map(logs.map(([r, sha]) => [r.slice(`${ref}/log/`.length), sha]));
|
|
284
|
+
const checkAccepted = (who, sha) => {
|
|
285
|
+
const tip = byAuthor.get(who);
|
|
286
|
+
if (!tip || !this.extends(sha, tip))
|
|
287
|
+
fail(`ALARM log ${who} lost accepted history; migration stopped`);
|
|
288
|
+
};
|
|
289
|
+
for (const [r, sha] of this.refsUnder(`refs/ringi/accepted/${genesis}/`))
|
|
290
|
+
checkAccepted(r.split('/').at(-1), sha);
|
|
291
|
+
for (const f of files) {
|
|
292
|
+
const st = strictJson(fs.readFileSync(path.join(readers, f), 'utf8'), f);
|
|
293
|
+
if (st.control && !this.extends(st.control, head))
|
|
294
|
+
fail('ALARM room control lost accepted history; migration stopped');
|
|
295
|
+
for (const [who, sha] of Object.entries(st.heads ?? {}))
|
|
296
|
+
checkAccepted(who, sha);
|
|
297
|
+
}
|
|
298
|
+
const tags = this.migrateSignals(name, genesis, [head, ...logs.map(([, id]) => id)], files.map(f => path.join(readers, f)));
|
|
299
|
+
const sha = this.commit(ROOT, compose(`migrate room ${name}`, [['To', '*'], ['Kind', 'room'], ['Ringi-Layout', 'branch/1']]), this.git(['rev-parse', `${head}^{tree}`]).trim(), [...new Set([head, ...logs.map(([, id]) => id)])]);
|
|
300
|
+
const old = [[ctl, head], ...logs];
|
|
301
|
+
const backup = `refs/ringi/legacy/${genesis}/`;
|
|
302
|
+
this.git(['update-ref', '--stdin'], { input: ['start', `create ${pending} ${sha}`,
|
|
303
|
+
...old.flatMap(([r, id]) => [`create ${backup}${r.slice(`${ref}/`.length)} ${id}`, `delete ${r} ${id}`]),
|
|
304
|
+
'prepare', 'commit', ''].join('\n') });
|
|
305
|
+
finish(sha);
|
|
306
|
+
tags();
|
|
307
|
+
return sha;
|
|
308
|
+
};
|
|
309
|
+
mkdirIn(this.home, path.join(this.home, 'locks'));
|
|
310
|
+
return protect(0);
|
|
311
|
+
}
|
|
312
|
+
// An asserted hub can explicitly convert its old tag annotations, keeping their target, tagger and date. The
|
|
313
|
+
// original objects and reader snapshots remain evidence. Ref changes are atomic; a repeat resumes reader writes
|
|
314
|
+
// after a crash without replacing an accepted object that changed or disappeared in either format.
|
|
315
|
+
migrateSignals(room, genesis, tips, readers) {
|
|
316
|
+
const prefix = `refs/tags/ringi/${genesis}/`, backup = `refs/ringi/legacy/${genesis}/signals/`;
|
|
317
|
+
const old = new Map(this.refsUnder(prefix)), saved = new Map(this.refsUnder(backup));
|
|
318
|
+
const liveTags = new Map(this.refsUnder('refs/tags/'));
|
|
319
|
+
const refs = [];
|
|
320
|
+
const changes = new Map();
|
|
321
|
+
const identities = new Map();
|
|
322
|
+
const targets = new Set(this.git(['rev-list', ...tips, `^${genesis}`]).trim().split('\n').filter(Boolean));
|
|
323
|
+
const kept = (sha) => tips.some(tip => this.extends(sha, tip));
|
|
324
|
+
for (const [ref, sha] of this.refsUnder(`refs/ringi/accepted/${genesis}/`)) {
|
|
325
|
+
if (!kept(sha))
|
|
326
|
+
fail(`ALARM ${ref} lost accepted history; migration stopped`);
|
|
327
|
+
}
|
|
328
|
+
for (const suffix of new Set([...old.keys()].map(r => r.slice(prefix.length)).concat([...saved.keys()].map(r => r.slice(backup.length))))) {
|
|
329
|
+
const before = saved.get(backup + suffix) ?? old.get(prefix + suffix);
|
|
330
|
+
const liveOld = old.get(prefix + suffix), recorded = saved.get(backup + suffix);
|
|
331
|
+
if (recorded && liveOld && recorded !== liveOld)
|
|
332
|
+
fail(`ALARM signal ${prefix + suffix} changed after migration; stopped`);
|
|
333
|
+
const m = /^([^/]+)\/((?:[0-9a-f]{40}|[0-9a-f]{64}))\.([a-z][a-z0-9.-]*)$/.exec(suffix);
|
|
334
|
+
if (!m || !AGENT_RE.test(m[1]))
|
|
335
|
+
return fail(`ALARM signal ${prefix + suffix} cannot be migrated to an unambiguous agent id`);
|
|
336
|
+
const [, actor, target, signal] = m;
|
|
337
|
+
if (this.git(['cat-file', '-t', before]).trim() !== 'tag')
|
|
338
|
+
fail(`ALARM signal ${prefix + suffix} is not annotated`);
|
|
339
|
+
const text = this.git(['cat-file', 'tag', before]);
|
|
340
|
+
const split = text.indexOf('\n\n'), header = text.slice(0, split), body = text.slice(split + 2);
|
|
341
|
+
const h = /^object ([0-9a-f]+)\ntype commit\ntag ([^\n]+)\n(tagger ([^<\n]+) <[^\n]*> \d+ [+-]\d{4})$/.exec(header);
|
|
342
|
+
if (!h || h[1] !== target || h[2] !== (prefix + suffix).slice('refs/tags/'.length) || h[4].trim() !== actor || !targets.has(target))
|
|
343
|
+
return fail(`ALARM signal ${prefix + suffix} disagrees with its name, actor or room target`);
|
|
344
|
+
if (body.includes('-----BEGIN'))
|
|
345
|
+
fail('signed legacy signals need their authors to sign the new names; migration stopped');
|
|
346
|
+
const p = parse(body, s => this.isRef(s));
|
|
347
|
+
for (const [k, value] of [['room', room], ['genesis', genesis], ['actor', actor], ['message', target], ['signal', signal]]) {
|
|
348
|
+
if (p.trailers[k]?.length !== 1 || p.trailers[k][0] !== value)
|
|
349
|
+
fail(`ALARM signal ${prefix + suffix} has inconsistent legacy metadata`);
|
|
350
|
+
}
|
|
351
|
+
const key = `${actor}.${signal}.${target.slice(0, 12)}`, ref = `refs/tags/${key}`;
|
|
352
|
+
const after = this.git(['mktag'], { input: `object ${target}\ntype commit\ntag ${key}\n${h[3]}\n\n` }).trim();
|
|
353
|
+
const existing = changes.get(key), live = liveTags.get(ref);
|
|
354
|
+
if ((existing && existing.after !== after) || (live && live !== after))
|
|
355
|
+
fail(`ALARM signal ${key} collides with another tag; migration stopped`);
|
|
356
|
+
if (recorded && !live)
|
|
357
|
+
fail(`ALARM migrated signal ${key} was removed; migration stopped`);
|
|
358
|
+
changes.set(key, { before, after });
|
|
359
|
+
identities.set(before, { key, target, signal });
|
|
360
|
+
identities.set(after, { key, target, signal });
|
|
361
|
+
if (!recorded)
|
|
362
|
+
refs.push(`create ${backup + suffix} ${before}`);
|
|
363
|
+
if (!live)
|
|
364
|
+
refs.push(`create ${ref} ${after}`);
|
|
365
|
+
if (liveOld)
|
|
366
|
+
refs.push(`delete ${prefix + suffix} ${liveOld}`);
|
|
367
|
+
}
|
|
368
|
+
const planned = readers.map(file => {
|
|
369
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
370
|
+
const st = strictJson(text, file);
|
|
371
|
+
if (typeof st.control === 'string' && !kept(st.control))
|
|
372
|
+
fail('ALARM room control lost accepted history; migration stopped');
|
|
373
|
+
for (const sha of Object.values(st.heads ?? {})) {
|
|
374
|
+
if (!kept(sha))
|
|
375
|
+
fail('ALARM room log lost accepted history; migration stopped');
|
|
376
|
+
}
|
|
377
|
+
const next = {};
|
|
378
|
+
for (const [original, object] of Object.entries(st.signals ?? {})) {
|
|
379
|
+
const m = /(?:^|\/)([^/]+)\/((?:[0-9a-f]{40}|[0-9a-f]{64}))\.([a-z][a-z0-9.-]*)$/.exec(original);
|
|
380
|
+
// An early reader shortened a full ref to its last component and lost the actor. Its pinned object still
|
|
381
|
+
// proves the identity: recover it only from an exact, verified old object retained by this migration.
|
|
382
|
+
const bare = /^((?:[0-9a-f]{40}|[0-9a-f]{64}))\.([a-z][a-z0-9.-]*)$/.exec(original);
|
|
383
|
+
const identity = bare ? identities.get(object) : undefined;
|
|
384
|
+
if (bare && (!identity || identity.target !== bare[1] || identity.signal !== bare[2]))
|
|
385
|
+
fail(`ALARM accepted signal ${original} lost its verified identity; migration stopped`);
|
|
386
|
+
const key = m ? `${m[1]}.${m[3]}.${m[2].slice(0, 12)}` : identity?.key ?? original;
|
|
387
|
+
const change = changes.get(key);
|
|
388
|
+
if (m && !change)
|
|
389
|
+
fail(`ALARM accepted signal ${original} is missing; migration stopped`);
|
|
390
|
+
if (!change && /^[a-z0-9]+(?:[_-][a-z0-9]+)*\.[a-z][a-z0-9.-]*\.[0-9a-f]{12}$/.test(key)
|
|
391
|
+
&& liveTags.get(`refs/tags/${key}`) !== object)
|
|
392
|
+
fail(`ALARM accepted signal ${original} is missing or replaced; migration stopped`);
|
|
393
|
+
if (change && object !== change.before && object !== change.after)
|
|
394
|
+
fail(`ALARM accepted signal ${original} was replaced; migration stopped`);
|
|
395
|
+
const value = change?.after ?? object;
|
|
396
|
+
if (next[key] && next[key] !== value)
|
|
397
|
+
fail(`ALARM accepted signal ${key} has conflicting objects; migration stopped`);
|
|
398
|
+
next[key] = value;
|
|
399
|
+
}
|
|
400
|
+
return { file, text, st, next, changed: JSON.stringify(st.signals ?? {}) !== JSON.stringify(next) };
|
|
401
|
+
});
|
|
402
|
+
return () => {
|
|
403
|
+
if (refs.length)
|
|
404
|
+
this.git(['update-ref', '--stdin'], { input: ['start', ...refs, 'prepare', 'commit', ''].join('\n') });
|
|
405
|
+
for (const p of planned) {
|
|
406
|
+
if (!p.changed)
|
|
407
|
+
continue;
|
|
408
|
+
// Separate from the live readers directory so the snapshot is never mistaken for another reader.
|
|
409
|
+
const dir = path.join(this.home, 'migration', this.d.name, genesis, 'readers');
|
|
410
|
+
mkdirIn(this.home, dir);
|
|
411
|
+
const snapshot = path.join(dir, `${path.basename(p.file)}.${crypto.createHash('sha256').update(p.text).digest('hex')}`);
|
|
412
|
+
if (!fs.existsSync(snapshot))
|
|
413
|
+
fs.writeFileSync(snapshot, p.text, { flag: 'wx', mode: 0o600 });
|
|
414
|
+
else if (fs.readFileSync(snapshot, 'utf8') !== p.text)
|
|
415
|
+
fail('ALARM migration reader snapshot changed; migration stopped');
|
|
416
|
+
writeJsonAtomic(p.file, { ...p.st, signals: p.next });
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// A control record on top of the branch: the control files come from the last control record, never from the tip,
|
|
421
|
+
// and the messages' files are kept.
|
|
186
422
|
updateControl(room, who, text, files) {
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
fail(`room ${room.name}: its control moved; retry`);
|
|
191
|
-
return sha;
|
|
423
|
+
const kept = [...CONTROL_FILES].flatMap(n => { const c = this.controlFile(room, n); return c === null ? [] : [[n, c]]; });
|
|
424
|
+
const all = { ...Object.fromEntries(kept), ...files };
|
|
425
|
+
return this.onTop(room, who, text, Object.entries(all).map(([p, c]) => ({ path: p, content: c, mode: '100644' })), [...CONTROL_FILES]);
|
|
192
426
|
}
|
|
193
427
|
refsUnder(prefix) {
|
|
194
428
|
return this.git(['for-each-ref', '--format=%(refname) %(objectname)', prefix]).split('\n').filter(Boolean).map(l => l.split(' '));
|
|
195
429
|
}
|
|
430
|
+
// The branch moves under the archive; its signals stay where they are, on its commits.
|
|
196
431
|
archiveRoom(room, as) {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
this.git(['update-ref', `${to}${ref.slice(from.length)}`, sha, ZERO]);
|
|
201
|
-
this.git(['update-ref', '-d', ref, sha]);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
432
|
+
const tip = this.rev(this.ref(room.name)) ?? fail(`room ${room.name} is gone`);
|
|
433
|
+
this.git(['update-ref', `refs/archive/${as}`, tip, ZERO]);
|
|
434
|
+
this.git(['update-ref', '-d', this.ref(room.name), tip]);
|
|
204
435
|
}
|
|
205
436
|
deleteRoom(room) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
437
|
+
const signals = this.signals(room);
|
|
438
|
+
this.gitOk(['update-ref', '-d', this.ref(room.name)]);
|
|
439
|
+
for (const [ref] of this.refsUnder(`refs/ringi/accepted/${room.genesis}/`))
|
|
440
|
+
this.git(['update-ref', '-d', ref]);
|
|
441
|
+
for (const s of signals)
|
|
442
|
+
this.gitOk(['update-ref', '-d', s.ref]);
|
|
210
443
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return { name: room.name, head: room.head, genesis: room.genesis, archived: room.archived, control: `${base}/control`, log: (who) => `${base}/log/${who}` };
|
|
444
|
+
remote(room) {
|
|
445
|
+
return { name: room.name, ref: this.ref(room.name), head: room.head, genesis: room.genesis, archived: room.archived };
|
|
214
446
|
}
|
|
215
447
|
roster(room) {
|
|
216
448
|
if (this.trust !== 'signed' || room.archived)
|
|
217
449
|
return null;
|
|
218
|
-
const v = Remote.verifyRoom(this.h, this.
|
|
450
|
+
const v = Remote.verifyRoom(this.h, this.remote(room));
|
|
219
451
|
return { members: v.members, signers: v.entries };
|
|
220
452
|
}
|
|
221
453
|
// --- logs ---
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
454
|
+
// Logs are read at the branch's live tip, as a reader sees the room now.
|
|
455
|
+
live(room) {
|
|
456
|
+
const tip = this.tip(room.name);
|
|
457
|
+
return tip ? this.entries(room, tip).filter(e => !e.control && !e.skipped) : [];
|
|
458
|
+
}
|
|
459
|
+
// What a signed hub skips in the room's branch, as alarms; the room reads on without it.
|
|
460
|
+
alarms(room) {
|
|
461
|
+
const tip = this.tip(room.name);
|
|
462
|
+
return tip ? this.entries(room, tip).filter(e => e.skipped).map(e => `commit ${e.sha.slice(0, 12)} by ${e.author || '(none)'}: ${e.skipped}`) : [];
|
|
225
463
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
// the
|
|
464
|
+
logIds(room) { return [...new Set(this.live(room).map(e => e.author))]; }
|
|
465
|
+
logHead(room, id) { return this.live(room).filter(e => e.author === id).at(-1)?.sha ?? null; }
|
|
466
|
+
// One author's messages after the genesis up to `head`, oldest first, read as bytes: the text is the commit message,
|
|
467
|
+
// the files are the explicit Ringi-Files list, or the paths the commit adds or changes for records without it.
|
|
229
468
|
messages(room, id, head) {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
469
|
+
const tip = this.trust === 'signed' ? this.tip(room.name) : null;
|
|
470
|
+
const skipped = new Set(tip ? this.entries(room, tip).filter(e => e.skipped).map(e => e.sha) : []);
|
|
471
|
+
const raw = runGitRaw(this.args(['log', '--topo-order', '--reverse', '--no-renames', '--raw', '--no-abbrev', '--format=%x1e%H%x1f%P%x1f%ct%x1f', head, `^${room.genesis}`]), this.home);
|
|
472
|
+
const records = splitBuf(raw, 0x1e).filter(b => b.length).map(rec => splitBuf(rec, 0x1f));
|
|
473
|
+
const commits = new Map(Remote.readCommits(this.h, records.map(([sha]) => sha.toString())).map(c => [c.sha, c]));
|
|
474
|
+
return records.flatMap(([sha, parent, ct, rest = Buffer.alloc(0)]) => {
|
|
475
|
+
const { author, body } = commits.get(sha.toString());
|
|
476
|
+
if (author !== id || this.isControl(room, sha.toString(), id, body.toString('utf8')) || skipped.has(sha.toString()))
|
|
477
|
+
return [];
|
|
233
478
|
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
479
|
.filter(([, , status]) => status !== 'D').map(([, mode, , p]) => ({ path: p, mode }));
|
|
235
480
|
const declared = parse(body.toString('utf8'), s => this.isRef(s)).trailers['ringi-files'];
|
|
@@ -258,34 +503,42 @@ export class GitStore {
|
|
|
258
503
|
for (const p of wanted)
|
|
259
504
|
files.push({ path: p, mode: 'missing' });
|
|
260
505
|
}
|
|
261
|
-
return { id: sha.toString(), parent: parent.toString() || null, time: Number(ct.toString()), text: body,
|
|
262
|
-
|
|
506
|
+
return [{ id: sha.toString(), parent: parent.toString() || null, time: Number(ct.toString()), text: body,
|
|
507
|
+
files: files.filter(f => REGULAR.has(f.mode)), refused: [...refused, ...files.filter(f => !REGULAR.has(f.mode))] }];
|
|
263
508
|
});
|
|
264
509
|
}
|
|
265
|
-
// One
|
|
510
|
+
// One message on top of the room's branch.
|
|
266
511
|
append(room, who, text, entries) {
|
|
267
512
|
if (room.archived)
|
|
268
513
|
fail(`room ${room.name} is archived: read-only`);
|
|
269
514
|
if (checkOutgoing(text, s => this.isRef(s)).trailers['ringi-files'])
|
|
270
515
|
fail('Ringi-Files is store metadata; supply attachments instead');
|
|
516
|
+
const own = entries.find(e => CONTROL_FILES.has(e.path));
|
|
517
|
+
if (own)
|
|
518
|
+
fail(`${own.path} is the room's own file; attach it under another path`);
|
|
271
519
|
if (entries.length)
|
|
272
520
|
text = `${text.trimEnd()}\nRingi-Files: ${JSON.stringify([...new Set(entries.map(e => e.path))])}\n`;
|
|
273
521
|
if (Buffer.byteLength(text) > TEXT_MAX)
|
|
274
522
|
fail('attachment list makes the message exceed 64 KiB; use fewer or shorter paths');
|
|
275
523
|
checkOutgoing(text, s => this.isRef(s));
|
|
276
|
-
|
|
524
|
+
return this.onTop(room, who, text, entries);
|
|
525
|
+
}
|
|
526
|
+
// A commit on the branch's tip, compare-and-swap: when another writer got there first, it is made again on the new tip.
|
|
527
|
+
onTop(room, who, text, entries, drop = []) {
|
|
528
|
+
const ref = this.ref(room.name);
|
|
277
529
|
for (let i = 0; i < 8; i++) {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
530
|
+
const tip = this.rev(ref) ?? fail(`room ${room.name} is gone`);
|
|
531
|
+
if (drop.length && this.room(room.name)?.head !== room.head)
|
|
532
|
+
fail('room control moved while writing; retry the operation');
|
|
533
|
+
const tree = entries.length || drop.length ? this.tree(`${tip}^{tree}`, entries, drop) : this.git(['rev-parse', `${tip}^{tree}`]).trim();
|
|
534
|
+
const sha = this.commit(who, text, tree, [tip]);
|
|
535
|
+
if (this.advance(ref, sha, tip))
|
|
283
536
|
return sha;
|
|
284
537
|
}
|
|
285
|
-
return fail(`
|
|
538
|
+
return fail(`room ${room.name} kept moving while writing; retry`);
|
|
286
539
|
}
|
|
287
540
|
verifyLog(room, id, head) {
|
|
288
|
-
return this.trust === 'signed' ? Remote.verify(this.h, this.
|
|
541
|
+
return this.trust === 'signed' ? Remote.verify(this.h, this.remote(room), id, head) : null;
|
|
289
542
|
}
|
|
290
543
|
pin(room, reader, id, rev) {
|
|
291
544
|
if (!room.archived)
|
|
@@ -304,10 +557,10 @@ export class GitStore {
|
|
|
304
557
|
.filter(([, , p]) => wanted.has(p)).map(([, n, p]) => [p, Number(n)]));
|
|
305
558
|
}
|
|
306
559
|
// --- signals ---
|
|
307
|
-
// An annotated tag in its author's namespace
|
|
308
|
-
//
|
|
309
|
-
signal(room, who, target, name
|
|
310
|
-
const tag =
|
|
560
|
+
// An annotated tag without text in its author's namespace, on the message's commit, signed on a signed hub. The
|
|
561
|
+
// message's id in the name keeps it unique, so writing the same signal twice is a no-op.
|
|
562
|
+
signal(room, who, target, name) {
|
|
563
|
+
const tag = `${who}.${name}`;
|
|
311
564
|
const ref = `refs/tags/${tag}`;
|
|
312
565
|
if (this.gitOk(['rev-parse', '-q', '--verify', ref]).status === 0) {
|
|
313
566
|
Remote.push(this.h, ref);
|
|
@@ -315,31 +568,68 @@ export class GitStore {
|
|
|
315
568
|
}
|
|
316
569
|
const sign = Remote.sign(this.h, who, this.home);
|
|
317
570
|
const key = sign[0]?.slice(2);
|
|
318
|
-
this.git([...(key ? ['-c', `user.signingkey=${key}`] : []), 'tag', key ? '-s' : '-a', '-F', '-', tag, target], { input:
|
|
571
|
+
this.git([...(key ? ['-c', `user.signingkey=${key}`] : []), 'tag', key ? '-s' : '-a', '-F', '-', tag, target], { input: '', env: ident(who) });
|
|
319
572
|
Remote.push(this.h, ref);
|
|
320
573
|
}
|
|
574
|
+
// The tags named `<actor>.<signal>.<message12>`, with the objects they name.
|
|
575
|
+
tags() {
|
|
576
|
+
return this.git(['for-each-ref', '--format=%(refname) %(objectname) %(objecttype)', 'refs/tags/']).split('\n').flatMap(line => {
|
|
577
|
+
const [ref, object, type] = line.split(' ');
|
|
578
|
+
const m = /^refs\/tags\/([a-z0-9]+(?:[_-][a-z0-9]+)*)\.([a-z][a-z0-9.-]*\.[0-9a-f]{12})$/.exec(ref ?? '');
|
|
579
|
+
return m ? [{ ref, actor: m[1], name: m[2], object, type }] : [];
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
// Annotated tag objects read whole, size-framed: their target, tagger and text are never split on bytes they hold.
|
|
583
|
+
tagObjects(shas) {
|
|
584
|
+
const out = new Map();
|
|
585
|
+
if (!shas.length)
|
|
586
|
+
return out;
|
|
587
|
+
const raw = runGitRaw(this.args(['cat-file', '--batch']), this.home, Buffer.from(shas.join('\n') + '\n'));
|
|
588
|
+
let at = 0;
|
|
589
|
+
for (const sha of shas) {
|
|
590
|
+
const nl = raw.indexOf(0x0a, at);
|
|
591
|
+
const [got, type, size] = raw.subarray(at, nl).toString('utf8').split(' ');
|
|
592
|
+
const body = raw.subarray(nl + 1, nl + 1 + Number(size));
|
|
593
|
+
at = nl + 1 + Number(size) + 1;
|
|
594
|
+
if (got !== sha || type !== 'tag')
|
|
595
|
+
continue;
|
|
596
|
+
const split = body.indexOf('\n\n');
|
|
597
|
+
const hdr = body.subarray(0, split < 0 ? body.length : split).toString('utf8');
|
|
598
|
+
const text = split < 0 ? '' : body.subarray(split + 2).toString('utf8').split('-----BEGIN SSH SIGNATURE-----')[0];
|
|
599
|
+
const target = /^object ([0-9a-f]+)\ntype commit$/m.exec(hdr)?.[1] ?? '';
|
|
600
|
+
out.set(sha, { target, tagger: /^tagger (.*?) <[^>\n]*> \d+ [+-]\d{4}$/m.exec(hdr)?.[1] ?? '', text });
|
|
601
|
+
}
|
|
602
|
+
return out;
|
|
603
|
+
}
|
|
604
|
+
// Every commit of the room's branch: a signal belongs to the room whose branch holds its message.
|
|
605
|
+
commitsOf(room) {
|
|
606
|
+
const tip = this.tip(room.name);
|
|
607
|
+
return new Set(tip ? this.entries(room, tip).map(e => e.sha) : []);
|
|
608
|
+
}
|
|
321
609
|
signals(room) {
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
const records =
|
|
325
|
-
const
|
|
326
|
-
const [actor, ...rest] = ref.slice(prefix.length).split('/');
|
|
610
|
+
const tags = this.tags(), objects = this.tagObjects(tags.filter(t => t.type === 'tag').map(t => t.object));
|
|
611
|
+
const mine = this.commitsOf(room);
|
|
612
|
+
const records = tags.flatMap(t => {
|
|
613
|
+
const o = objects.get(t.object);
|
|
327
614
|
// Only an annotated tag on a commit is a signal; anything else points nowhere.
|
|
328
|
-
|
|
615
|
+
if (!mine.has(o?.target || t.object))
|
|
616
|
+
return [];
|
|
617
|
+
return [{ ref: t.ref, object: t.object, name: t.name, actor: t.actor, target: o?.target ?? '', tagger: o?.tagger ?? '', text: o?.text ?? '' }];
|
|
329
618
|
});
|
|
330
619
|
if (this.trust !== 'signed' || room.archived)
|
|
331
620
|
return records;
|
|
332
621
|
return Remote.withVerification(this.h, () => records.filter(s => {
|
|
333
|
-
const why = Remote.verifySignal(this.h, this.
|
|
622
|
+
const why = Remote.verifySignal(this.h, this.remote(room), s.ref, s.object);
|
|
334
623
|
if (why)
|
|
335
624
|
note(`ALARM signal ${s.ref}: ${why}; quarantined and withheld`);
|
|
336
625
|
return !why;
|
|
337
626
|
}));
|
|
338
627
|
}
|
|
339
628
|
// --- signed stores ---
|
|
340
|
-
admit(room, who, key) { return Remote.roomAdmit(this.h, this.
|
|
341
|
-
joinKey(room, who) { return Remote.join(this.h, this.
|
|
342
|
-
|
|
629
|
+
admit(room, who, key) { return Remote.roomAdmit(this.h, this.remote(room), who, key, this.home); }
|
|
630
|
+
joinKey(room, who) { return Remote.join(this.h, this.remote(room), who, this.home); }
|
|
631
|
+
// A room's transport alarm is the room's, whichever author the operator forgets.
|
|
632
|
+
forgetLog(room, _id) { Remote.forget(this.h, this.remote(room)); }
|
|
343
633
|
rootKey() { return this.d.root ?? fail(`hub ${this.d.name} has no pinned root`); }
|
|
344
634
|
rootRoster() { return Remote.rootRoster(this.h); }
|
|
345
635
|
}
|