@idleware/ringi 0.1.1 → 0.3.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 +10 -1
- package/target/esm/app/session.js +3 -3
- package/target/esm/cli/index.js +2 -1
- package/target/esm/cli/operator.js +5 -1
- 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/runtime/session.js +7 -3
- package/target/esm/runtime/watch.js +9 -2
- package/target/esm/store/git/index.js +382 -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,259 @@ 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
|
}
|
|
436
|
+
// Every ref of the room in one transaction: its branch (or archive) and a pending migration, every tag on its
|
|
437
|
+
// commits (signals, ringi 0.1's, anyone's), what the store keeps under its genesis (pins, ringi 0.1's refs, evidence)
|
|
438
|
+
// and its unpublished commits; then the migration's backup of its readers. Objects are left to the repository.
|
|
205
439
|
deleteRoom(room) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
440
|
+
const commits = this.commitsOf(room);
|
|
441
|
+
const tags = this.git(['for-each-ref', '--format=%(refname) %(objectname) %(*objectname)', 'refs/tags/']).split('\n').filter(Boolean)
|
|
442
|
+
.map(l => l.split(' ')).filter(([ref, object, target]) => commits.has(target || object) || ref.startsWith(`refs/tags/ringi/${room.genesis}/`)).map(([ref]) => ref);
|
|
443
|
+
const kept = this.refsUnder('refs/ringi/').map(([ref]) => ref).filter(ref => {
|
|
444
|
+
const [, , kind, key] = ref.split('/');
|
|
445
|
+
return key === room.genesis || (kind === 'unpublished' && commits.has(key));
|
|
446
|
+
});
|
|
447
|
+
const refs = [this.ref(room.name), this.transition(room.name)].filter(r => this.rev(r)).concat(tags, kept);
|
|
448
|
+
this.git(['update-ref', '--stdin'], { input: ['start', ...refs.map(r => `delete ${r}`), 'prepare', 'commit', ''].join('\n') });
|
|
449
|
+
fs.rmSync(path.join(this.home, 'migration', this.d.name, room.genesis), { recursive: true, force: true });
|
|
210
450
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return { name: room.name, head: room.head, genesis: room.genesis, archived: room.archived, control: `${base}/control`, log: (who) => `${base}/log/${who}` };
|
|
451
|
+
remote(room) {
|
|
452
|
+
return { name: room.name, ref: this.ref(room.name), head: room.head, genesis: room.genesis, archived: room.archived };
|
|
214
453
|
}
|
|
215
454
|
roster(room) {
|
|
216
455
|
if (this.trust !== 'signed' || room.archived)
|
|
217
456
|
return null;
|
|
218
|
-
const v = Remote.verifyRoom(this.h, this.
|
|
457
|
+
const v = Remote.verifyRoom(this.h, this.remote(room));
|
|
219
458
|
return { members: v.members, signers: v.entries };
|
|
220
459
|
}
|
|
221
460
|
// --- logs ---
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
461
|
+
// Logs are read at the branch's live tip, as a reader sees the room now.
|
|
462
|
+
live(room) {
|
|
463
|
+
const tip = this.tip(room.name);
|
|
464
|
+
return tip ? this.entries(room, tip).filter(e => !e.control && !e.skipped) : [];
|
|
465
|
+
}
|
|
466
|
+
// What a signed hub skips in the room's branch, as alarms; the room reads on without it.
|
|
467
|
+
alarms(room) {
|
|
468
|
+
const tip = this.tip(room.name);
|
|
469
|
+
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
470
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
// the
|
|
471
|
+
logIds(room) { return [...new Set(this.live(room).map(e => e.author))]; }
|
|
472
|
+
logHead(room, id) { return this.live(room).filter(e => e.author === id).at(-1)?.sha ?? null; }
|
|
473
|
+
// One author's messages after the genesis up to `head`, oldest first, read as bytes: the text is the commit message,
|
|
474
|
+
// the files are the explicit Ringi-Files list, or the paths the commit adds or changes for records without it.
|
|
229
475
|
messages(room, id, head) {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
476
|
+
const tip = this.trust === 'signed' ? this.tip(room.name) : null;
|
|
477
|
+
const skipped = new Set(tip ? this.entries(room, tip).filter(e => e.skipped).map(e => e.sha) : []);
|
|
478
|
+
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);
|
|
479
|
+
const records = splitBuf(raw, 0x1e).filter(b => b.length).map(rec => splitBuf(rec, 0x1f));
|
|
480
|
+
const commits = new Map(Remote.readCommits(this.h, records.map(([sha]) => sha.toString())).map(c => [c.sha, c]));
|
|
481
|
+
return records.flatMap(([sha, parent, ct, rest = Buffer.alloc(0)]) => {
|
|
482
|
+
const { author, body } = commits.get(sha.toString());
|
|
483
|
+
if (author !== id || this.isControl(room, sha.toString(), id, body.toString('utf8')) || skipped.has(sha.toString()))
|
|
484
|
+
return [];
|
|
233
485
|
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
486
|
.filter(([, , status]) => status !== 'D').map(([, mode, , p]) => ({ path: p, mode }));
|
|
235
487
|
const declared = parse(body.toString('utf8'), s => this.isRef(s)).trailers['ringi-files'];
|
|
@@ -258,34 +510,42 @@ export class GitStore {
|
|
|
258
510
|
for (const p of wanted)
|
|
259
511
|
files.push({ path: p, mode: 'missing' });
|
|
260
512
|
}
|
|
261
|
-
return { id: sha.toString(), parent: parent.toString() || null, time: Number(ct.toString()), text: body,
|
|
262
|
-
|
|
513
|
+
return [{ id: sha.toString(), parent: parent.toString() || null, time: Number(ct.toString()), text: body,
|
|
514
|
+
files: files.filter(f => REGULAR.has(f.mode)), refused: [...refused, ...files.filter(f => !REGULAR.has(f.mode))] }];
|
|
263
515
|
});
|
|
264
516
|
}
|
|
265
|
-
// One
|
|
517
|
+
// One message on top of the room's branch.
|
|
266
518
|
append(room, who, text, entries) {
|
|
267
519
|
if (room.archived)
|
|
268
520
|
fail(`room ${room.name} is archived: read-only`);
|
|
269
521
|
if (checkOutgoing(text, s => this.isRef(s)).trailers['ringi-files'])
|
|
270
522
|
fail('Ringi-Files is store metadata; supply attachments instead');
|
|
523
|
+
const own = entries.find(e => CONTROL_FILES.has(e.path));
|
|
524
|
+
if (own)
|
|
525
|
+
fail(`${own.path} is the room's own file; attach it under another path`);
|
|
271
526
|
if (entries.length)
|
|
272
527
|
text = `${text.trimEnd()}\nRingi-Files: ${JSON.stringify([...new Set(entries.map(e => e.path))])}\n`;
|
|
273
528
|
if (Buffer.byteLength(text) > TEXT_MAX)
|
|
274
529
|
fail('attachment list makes the message exceed 64 KiB; use fewer or shorter paths');
|
|
275
530
|
checkOutgoing(text, s => this.isRef(s));
|
|
276
|
-
|
|
531
|
+
return this.onTop(room, who, text, entries);
|
|
532
|
+
}
|
|
533
|
+
// A commit on the branch's tip, compare-and-swap: when another writer got there first, it is made again on the new tip.
|
|
534
|
+
onTop(room, who, text, entries, drop = []) {
|
|
535
|
+
const ref = this.ref(room.name);
|
|
277
536
|
for (let i = 0; i < 8; i++) {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
537
|
+
const tip = this.rev(ref) ?? fail(`room ${room.name} is gone`);
|
|
538
|
+
if (drop.length && this.room(room.name)?.head !== room.head)
|
|
539
|
+
fail('room control moved while writing; retry the operation');
|
|
540
|
+
const tree = entries.length || drop.length ? this.tree(`${tip}^{tree}`, entries, drop) : this.git(['rev-parse', `${tip}^{tree}`]).trim();
|
|
541
|
+
const sha = this.commit(who, text, tree, [tip]);
|
|
542
|
+
if (this.advance(ref, sha, tip))
|
|
283
543
|
return sha;
|
|
284
544
|
}
|
|
285
|
-
return fail(`
|
|
545
|
+
return fail(`room ${room.name} kept moving while writing; retry`);
|
|
286
546
|
}
|
|
287
547
|
verifyLog(room, id, head) {
|
|
288
|
-
return this.trust === 'signed' ? Remote.verify(this.h, this.
|
|
548
|
+
return this.trust === 'signed' ? Remote.verify(this.h, this.remote(room), id, head) : null;
|
|
289
549
|
}
|
|
290
550
|
pin(room, reader, id, rev) {
|
|
291
551
|
if (!room.archived)
|
|
@@ -304,10 +564,10 @@ export class GitStore {
|
|
|
304
564
|
.filter(([, , p]) => wanted.has(p)).map(([, n, p]) => [p, Number(n)]));
|
|
305
565
|
}
|
|
306
566
|
// --- signals ---
|
|
307
|
-
// An annotated tag in its author's namespace
|
|
308
|
-
//
|
|
309
|
-
signal(room, who, target, name
|
|
310
|
-
const tag =
|
|
567
|
+
// An annotated tag without text in its author's namespace, on the message's commit, signed on a signed hub. The
|
|
568
|
+
// message's id in the name keeps it unique, so writing the same signal twice is a no-op.
|
|
569
|
+
signal(room, who, target, name) {
|
|
570
|
+
const tag = `${who}.${name}`;
|
|
311
571
|
const ref = `refs/tags/${tag}`;
|
|
312
572
|
if (this.gitOk(['rev-parse', '-q', '--verify', ref]).status === 0) {
|
|
313
573
|
Remote.push(this.h, ref);
|
|
@@ -315,31 +575,68 @@ export class GitStore {
|
|
|
315
575
|
}
|
|
316
576
|
const sign = Remote.sign(this.h, who, this.home);
|
|
317
577
|
const key = sign[0]?.slice(2);
|
|
318
|
-
this.git([...(key ? ['-c', `user.signingkey=${key}`] : []), 'tag', key ? '-s' : '-a', '-F', '-', tag, target], { input:
|
|
578
|
+
this.git([...(key ? ['-c', `user.signingkey=${key}`] : []), 'tag', key ? '-s' : '-a', '-F', '-', tag, target], { input: '', env: ident(who) });
|
|
319
579
|
Remote.push(this.h, ref);
|
|
320
580
|
}
|
|
581
|
+
// The tags named `<actor>.<signal>.<message12>`, with the objects they name.
|
|
582
|
+
tags() {
|
|
583
|
+
return this.git(['for-each-ref', '--format=%(refname) %(objectname) %(objecttype)', 'refs/tags/']).split('\n').flatMap(line => {
|
|
584
|
+
const [ref, object, type] = line.split(' ');
|
|
585
|
+
const m = /^refs\/tags\/([a-z0-9]+(?:[_-][a-z0-9]+)*)\.([a-z][a-z0-9.-]*\.[0-9a-f]{12})$/.exec(ref ?? '');
|
|
586
|
+
return m ? [{ ref, actor: m[1], name: m[2], object, type }] : [];
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
// Annotated tag objects read whole, size-framed: their target, tagger and text are never split on bytes they hold.
|
|
590
|
+
tagObjects(shas) {
|
|
591
|
+
const out = new Map();
|
|
592
|
+
if (!shas.length)
|
|
593
|
+
return out;
|
|
594
|
+
const raw = runGitRaw(this.args(['cat-file', '--batch']), this.home, Buffer.from(shas.join('\n') + '\n'));
|
|
595
|
+
let at = 0;
|
|
596
|
+
for (const sha of shas) {
|
|
597
|
+
const nl = raw.indexOf(0x0a, at);
|
|
598
|
+
const [got, type, size] = raw.subarray(at, nl).toString('utf8').split(' ');
|
|
599
|
+
const body = raw.subarray(nl + 1, nl + 1 + Number(size));
|
|
600
|
+
at = nl + 1 + Number(size) + 1;
|
|
601
|
+
if (got !== sha || type !== 'tag')
|
|
602
|
+
continue;
|
|
603
|
+
const split = body.indexOf('\n\n');
|
|
604
|
+
const hdr = body.subarray(0, split < 0 ? body.length : split).toString('utf8');
|
|
605
|
+
const text = split < 0 ? '' : body.subarray(split + 2).toString('utf8').split('-----BEGIN SSH SIGNATURE-----')[0];
|
|
606
|
+
const target = /^object ([0-9a-f]+)\ntype commit$/m.exec(hdr)?.[1] ?? '';
|
|
607
|
+
out.set(sha, { target, tagger: /^tagger (.*?) <[^>\n]*> \d+ [+-]\d{4}$/m.exec(hdr)?.[1] ?? '', text });
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
// Every commit of the room's branch: a signal belongs to the room whose branch holds its message.
|
|
612
|
+
commitsOf(room) {
|
|
613
|
+
const tip = this.tip(room.name);
|
|
614
|
+
return new Set(tip ? this.entries(room, tip).map(e => e.sha) : []);
|
|
615
|
+
}
|
|
321
616
|
signals(room) {
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
const records =
|
|
325
|
-
const
|
|
326
|
-
const [actor, ...rest] = ref.slice(prefix.length).split('/');
|
|
617
|
+
const tags = this.tags(), objects = this.tagObjects(tags.filter(t => t.type === 'tag').map(t => t.object));
|
|
618
|
+
const mine = this.commitsOf(room);
|
|
619
|
+
const records = tags.flatMap(t => {
|
|
620
|
+
const o = objects.get(t.object);
|
|
327
621
|
// Only an annotated tag on a commit is a signal; anything else points nowhere.
|
|
328
|
-
|
|
622
|
+
if (!mine.has(o?.target || t.object))
|
|
623
|
+
return [];
|
|
624
|
+
return [{ ref: t.ref, object: t.object, name: t.name, actor: t.actor, target: o?.target ?? '', tagger: o?.tagger ?? '', text: o?.text ?? '' }];
|
|
329
625
|
});
|
|
330
626
|
if (this.trust !== 'signed' || room.archived)
|
|
331
627
|
return records;
|
|
332
628
|
return Remote.withVerification(this.h, () => records.filter(s => {
|
|
333
|
-
const why = Remote.verifySignal(this.h, this.
|
|
629
|
+
const why = Remote.verifySignal(this.h, this.remote(room), s.ref, s.object);
|
|
334
630
|
if (why)
|
|
335
631
|
note(`ALARM signal ${s.ref}: ${why}; quarantined and withheld`);
|
|
336
632
|
return !why;
|
|
337
633
|
}));
|
|
338
634
|
}
|
|
339
635
|
// --- signed stores ---
|
|
340
|
-
admit(room, who, key) { return Remote.roomAdmit(this.h, this.
|
|
341
|
-
joinKey(room, who) { return Remote.join(this.h, this.
|
|
342
|
-
|
|
636
|
+
admit(room, who, key) { return Remote.roomAdmit(this.h, this.remote(room), who, key, this.home); }
|
|
637
|
+
joinKey(room, who) { return Remote.join(this.h, this.remote(room), who, this.home); }
|
|
638
|
+
// A room's transport alarm is the room's, whichever author the operator forgets.
|
|
639
|
+
forgetLog(room, _id) { Remote.forget(this.h, this.remote(room)); }
|
|
343
640
|
rootKey() { return this.d.root ?? fail(`hub ${this.d.name} has no pinned root`); }
|
|
344
641
|
rootRoster() { return Remote.rootRoster(this.h); }
|
|
345
642
|
}
|