@idleware/ringi 0.3.0 → 0.5.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/README.md +1 -1
- package/package.json +5 -1
- package/src/main/resources/skill/ringi/SKILL.md +6 -3
- package/target/dts/app/agent.d.ts +5 -0
- package/target/dts/app/heartbeat.d.ts +33 -0
- package/target/dts/app/room.d.ts +5 -0
- package/target/dts/app/rooms.d.ts +2 -1
- package/target/dts/app/session.d.ts +6 -1
- package/target/dts/cli/operator.d.ts +2 -0
- package/target/dts/cli/shared.d.ts +7 -0
- package/target/dts/heartbeat/index.d.ts +35 -0
- package/target/dts/heartbeat/platform.d.ts +15 -0
- package/target/dts/heartbeat/tools.d.ts +11 -0
- package/target/dts/hub/index.d.ts +6 -0
- package/target/dts/protocol/files.d.ts +1 -0
- package/target/dts/runtime/reader.d.ts +7 -0
- package/target/dts/store/git/index.d.ts +14 -0
- package/target/dts/store/git/remote.d.ts +9 -0
- package/target/dts/store/index.d.ts +12 -0
- package/target/esm/app/agent.js +3 -1
- package/target/esm/app/heartbeat.js +110 -0
- package/target/esm/app/room.js +55 -1
- package/target/esm/app/rooms.js +7 -3
- package/target/esm/app/session.js +4 -2
- package/target/esm/cli/agent.js +8 -2
- package/target/esm/cli/args.js +2 -2
- package/target/esm/cli/index.js +13 -6
- package/target/esm/cli/operator.js +39 -5
- package/target/esm/cli/session.js +14 -3
- package/target/esm/cli/shared.js +17 -1
- package/target/esm/heartbeat/index.js +41 -0
- package/target/esm/heartbeat/platform.js +83 -0
- package/target/esm/heartbeat/tools.js +68 -0
- package/target/esm/hub/index.js +13 -1
- package/target/esm/runtime/watch.js +11 -1
- package/target/esm/store/git/index.js +82 -6
- package/target/esm/store/git/remote.js +170 -0
|
@@ -56,6 +56,9 @@ const splitBuf = (b, sep) => {
|
|
|
56
56
|
return parts;
|
|
57
57
|
};
|
|
58
58
|
const REGULAR = new Set(['100644', '100755']);
|
|
59
|
+
// The store is a repository whose work tree holds the contract (`main`) as it is now, so Git clients (SourceTree, IDEs)
|
|
60
|
+
// open `store/` without errors, on the contract, with every room's branch beside it; `git status` stays clean.
|
|
61
|
+
const init = (dir, home) => runGit(['init', '-q', '-b', 'main', path.dirname(dir)], { where: home });
|
|
59
62
|
// The root's principal, and the files only its control records may change.
|
|
60
63
|
const ROOT = 'operator';
|
|
61
64
|
const CONTROL_FILES = new Set(['ROOM.md', 'members.json', 'profile.json', 'allowed_signers']);
|
|
@@ -75,6 +78,30 @@ export class GitStore {
|
|
|
75
78
|
this.h = { name: d.name, dir: this.dir, d };
|
|
76
79
|
if (!fs.existsSync(this.dir))
|
|
77
80
|
fail(`hub ${d.name}: no git store at ${this.dir}`);
|
|
81
|
+
this.openable();
|
|
82
|
+
}
|
|
83
|
+
// A store made bare before, or a replica fetched bare, gets its work tree once (and loses the `_ringi` branch of an unreleased build).
|
|
84
|
+
openable() {
|
|
85
|
+
let changed = false;
|
|
86
|
+
if (/^\s*bare\s*=\s*true\s*$/m.test(fs.readFileSync(path.join(this.dir, 'config'), 'utf8'))) {
|
|
87
|
+
this.git(['config', 'core.bare', 'false']);
|
|
88
|
+
changed = true;
|
|
89
|
+
}
|
|
90
|
+
if (fs.readFileSync(path.join(this.dir, 'HEAD'), 'utf8') !== 'ref: refs/heads/main\n') {
|
|
91
|
+
this.git(['symbolic-ref', 'HEAD', 'refs/heads/main']);
|
|
92
|
+
changed = true;
|
|
93
|
+
}
|
|
94
|
+
if (fs.existsSync(path.join(this.dir, 'refs', 'heads', '_ringi'))) {
|
|
95
|
+
this.gitOk(['update-ref', '-d', 'refs/heads/_ringi']);
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
if (changed)
|
|
99
|
+
this.checkout();
|
|
100
|
+
}
|
|
101
|
+
// The work tree and the index follow the contract, so a Git client sees the store clean.
|
|
102
|
+
checkout() {
|
|
103
|
+
if (this.rev('refs/heads/main'))
|
|
104
|
+
this.git(['--work-tree', path.dirname(this.dir), 'read-tree', '-u', '--reset', 'refs/heads/main']);
|
|
78
105
|
}
|
|
79
106
|
args(a) { return ['--git-dir', this.dir, ...a]; }
|
|
80
107
|
git(a, o) { return runGit(this.args(a), { ...o, where: this.home }); }
|
|
@@ -97,7 +124,7 @@ export class GitStore {
|
|
|
97
124
|
if (drop.length)
|
|
98
125
|
this.git(['update-index', '--index-info'], { env, input: drop.map(p => `0 ${ZERO}\t${p}\n`).join('') });
|
|
99
126
|
for (const e of entries) {
|
|
100
|
-
const sha = this.git(['hash-object', '-w', ...(e.file ? ['--', e.file] : ['--stdin'])], { input: e.content }).trim();
|
|
127
|
+
const sha = e.blob ?? this.git(['hash-object', '-w', ...(e.file ? ['--', e.file] : ['--stdin'])], { input: e.content }).trim();
|
|
101
128
|
this.git(['update-index', '--add', '--cacheinfo', `${e.mode},${sha},${e.path}`], { env });
|
|
102
129
|
}
|
|
103
130
|
return this.git(['write-tree'], { env }).trim();
|
|
@@ -121,7 +148,7 @@ export class GitStore {
|
|
|
121
148
|
// A signed hub's repository already exists: rootFor made it with the root key.
|
|
122
149
|
if (!fs.existsSync(dir)) {
|
|
123
150
|
mkdirIn(home, path.dirname(dir));
|
|
124
|
-
|
|
151
|
+
init(dir, home);
|
|
125
152
|
}
|
|
126
153
|
const s = new GitStore(hubDir, d, home);
|
|
127
154
|
const sha = s.commit('operator', text, s.tree(null, Object.entries(contract).map(([p, c]) => ({ path: p, content: c, mode: '100644' }))), []);
|
|
@@ -129,17 +156,21 @@ export class GitStore {
|
|
|
129
156
|
d.genesis = sha;
|
|
130
157
|
if (!s.advance('refs/heads/main', sha, null))
|
|
131
158
|
fail(`hub ${d.name}: main already exists`);
|
|
159
|
+
s.checkout();
|
|
132
160
|
return s;
|
|
133
161
|
}
|
|
134
162
|
// A new signed hub: the root key is generated on this machine and pinned in the descriptor.
|
|
135
163
|
static rootFor(hubDir, d, home) {
|
|
136
164
|
const dir = path.join(hubDir, d.store.path, '.git');
|
|
137
165
|
mkdirIn(home, path.dirname(dir));
|
|
138
|
-
|
|
166
|
+
init(dir, home);
|
|
139
167
|
return Remote.setupSigned({ name: d.name, dir, d }, home, d.store.remote).root;
|
|
140
168
|
}
|
|
141
169
|
sync() {
|
|
170
|
+
const main = this.rev('refs/heads/main');
|
|
142
171
|
Remote.sync(this.h);
|
|
172
|
+
if (this.rev('refs/heads/main') !== main)
|
|
173
|
+
this.checkout();
|
|
143
174
|
}
|
|
144
175
|
// --- the contract ---
|
|
145
176
|
contractHead() { return this.rev('refs/heads/main') ?? fail(`hub ${this.d.name} has no contract (main)`); }
|
|
@@ -153,6 +184,7 @@ export class GitStore {
|
|
|
153
184
|
const sha = this.commit(who, text, this.tree(`${head}^{tree}`, Object.entries(files).map(([p, c]) => ({ path: p, content: c, mode: '100644' }))), [head]);
|
|
154
185
|
if (!this.advance('refs/heads/main', sha, head))
|
|
155
186
|
fail('the contract moved while amending; retry');
|
|
187
|
+
this.checkout();
|
|
156
188
|
return sha;
|
|
157
189
|
}
|
|
158
190
|
// --- rooms ---
|
|
@@ -163,7 +195,7 @@ export class GitStore {
|
|
|
163
195
|
roomNames(archived) {
|
|
164
196
|
const prefix = archived ? 'refs/archive/' : 'refs/heads/';
|
|
165
197
|
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 =>
|
|
198
|
+
.map(n => n.replace(/\/control$/, '')).filter(n => ID_RE.test(n.split('@')[0]) && n !== 'main' && n.includes('@') === archived);
|
|
167
199
|
return [...new Set(names)].sort();
|
|
168
200
|
}
|
|
169
201
|
// A control record: the room's genesis, or a root commit of Kind: room.
|
|
@@ -514,6 +546,19 @@ export class GitStore {
|
|
|
514
546
|
files: files.filter(f => REGULAR.has(f.mode)), refused: [...refused, ...files.filter(f => !REGULAR.has(f.mode))] }];
|
|
515
547
|
});
|
|
516
548
|
}
|
|
549
|
+
blob(file) {
|
|
550
|
+
const id = this.git(['hash-object', '-w', '--', file]).trim();
|
|
551
|
+
return { id, size: Number(this.git(['cat-file', '-s', id]).trim()) };
|
|
552
|
+
}
|
|
553
|
+
// A tree of the staged files under refs/ringi/staged/<genesis>/<who>: git keeps what a ref reaches.
|
|
554
|
+
retain(room, who, entries) {
|
|
555
|
+
const ref = `refs/ringi/staged/${room.genesis}/${who}`;
|
|
556
|
+
if (!entries.length) {
|
|
557
|
+
this.gitOk(['update-ref', '-d', ref]);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
this.git(['update-ref', ref, this.tree(null, entries)]);
|
|
561
|
+
}
|
|
517
562
|
// One message on top of the room's branch.
|
|
518
563
|
append(room, who, text, entries) {
|
|
519
564
|
if (room.archived)
|
|
@@ -564,6 +609,35 @@ export class GitStore {
|
|
|
564
609
|
.filter(([, , p]) => wanted.has(p)).map(([, n, p]) => [p, Number(n)]));
|
|
565
610
|
}
|
|
566
611
|
// --- signals ---
|
|
612
|
+
// The heartbeat of `who` in the room: a tag `<who>.heartbeat.<genesis12>` on the room's last commit here, its text the
|
|
613
|
+
// facts of the beat (heartbeat/). Each beat replaces the object under the same name, compare-and-swap on the one it
|
|
614
|
+
// read, so two writers do not overwrite each other blindly. No reader pins it; its replacement is no alarm.
|
|
615
|
+
// On a signed hub the object is signed by `who` and published with the OID it replaced; a newer beat there wins, and
|
|
616
|
+
// this one is taken back here (remote.ts).
|
|
617
|
+
heartbeat(room, who, target, text) {
|
|
618
|
+
const ref = Remote.heartbeatRef(who, room.genesis), name = ref.slice('refs/tags/'.length);
|
|
619
|
+
const old = this.rev(`${ref}^{tag}`) ? this.git(['rev-parse', ref]).trim() : null;
|
|
620
|
+
const obj = this.trust === 'signed' ? Remote.makeHeartbeat(this.h, this.remote(room), who, target, text, this.home)
|
|
621
|
+
: this.git(['mktag'], { input: `object ${target}\ntype commit\ntag ${name}\ntagger ${who} <${who}@ringi> ${Math.floor(Date.now() / 1000)} +0000\n\n${text}` }).trim();
|
|
622
|
+
if (this.gitOk(['update-ref', ref, obj, old ?? ZERO]).status !== 0)
|
|
623
|
+
fail(`${name} moved meanwhile; the next beat writes it`);
|
|
624
|
+
if (this.trust === 'signed' && !Remote.pushHeartbeat(this.h, this.remote(room), who, obj, old))
|
|
625
|
+
this.gitOk(['update-ref', ...(old ? [ref, old, obj] : ['-d', ref, obj])]);
|
|
626
|
+
return obj;
|
|
627
|
+
}
|
|
628
|
+
// The room's heartbeats as they stand: whose, on which commit, when, and what they say.
|
|
629
|
+
heartbeats(room) {
|
|
630
|
+
const g12 = room.genesis.slice(0, 12);
|
|
631
|
+
const tags = this.git(['for-each-ref', '--format=%(refname) %(objectname) %(objecttype)', 'refs/tags/']).split('\n')
|
|
632
|
+
.map(l => l.split(' ')).filter(([ref, , type]) => type === 'tag' && Remote.heartbeatName(ref ?? '')?.genesis12 === g12);
|
|
633
|
+
const objects = this.tagObjects(tags.map(([, object]) => object));
|
|
634
|
+
// On a signed hub only a verified beat shows.
|
|
635
|
+
const ok = (ref, object) => this.trust !== 'signed' || !Remote.verifyHeartbeat(this.h, this.remote(room), ref, object);
|
|
636
|
+
return tags.flatMap(([ref, object]) => {
|
|
637
|
+
const o = objects.get(object);
|
|
638
|
+
return o && ok(ref, object) ? [{ agent: Remote.heartbeatName(ref).actor, target: o.target, time: o.time, text: o.text }] : [];
|
|
639
|
+
});
|
|
640
|
+
}
|
|
567
641
|
// An annotated tag without text in its author's namespace, on the message's commit, signed on a signed hub. The
|
|
568
642
|
// message's id in the name keeps it unique, so writing the same signal twice is a no-op.
|
|
569
643
|
signal(room, who, target, name) {
|
|
@@ -583,7 +657,8 @@ export class GitStore {
|
|
|
583
657
|
return this.git(['for-each-ref', '--format=%(refname) %(objectname) %(objecttype)', 'refs/tags/']).split('\n').flatMap(line => {
|
|
584
658
|
const [ref, object, type] = line.split(' ');
|
|
585
659
|
const m = /^refs\/tags\/([a-z0-9]+(?:[_-][a-z0-9]+)*)\.([a-z][a-z0-9.-]*\.[0-9a-f]{12})$/.exec(ref ?? '');
|
|
586
|
-
|
|
660
|
+
// A heartbeat is no signal: it is replaced on every beat, and readers judge it apart (heartbeats()).
|
|
661
|
+
return m && !Remote.heartbeatName(ref) ? [{ ref, actor: m[1], name: m[2], object, type }] : [];
|
|
587
662
|
});
|
|
588
663
|
}
|
|
589
664
|
// Annotated tag objects read whole, size-framed: their target, tagger and text are never split on bytes they hold.
|
|
@@ -604,7 +679,8 @@ export class GitStore {
|
|
|
604
679
|
const hdr = body.subarray(0, split < 0 ? body.length : split).toString('utf8');
|
|
605
680
|
const text = split < 0 ? '' : body.subarray(split + 2).toString('utf8').split('-----BEGIN SSH SIGNATURE-----')[0];
|
|
606
681
|
const target = /^object ([0-9a-f]+)\ntype commit$/m.exec(hdr)?.[1] ?? '';
|
|
607
|
-
|
|
682
|
+
const tagger = /^tagger (.*?) <[^>\n]*> (\d+) [+-]\d{4}$/m.exec(hdr);
|
|
683
|
+
out.set(sha, { target, tagger: tagger?.[1] ?? '', time: Number(tagger?.[2] ?? NaN), text });
|
|
608
684
|
}
|
|
609
685
|
return out;
|
|
610
686
|
}
|
|
@@ -382,8 +382,113 @@ export const verify = (H, R, who, head) => {
|
|
|
382
382
|
return reason(e);
|
|
383
383
|
}
|
|
384
384
|
};
|
|
385
|
+
// Heartbeats deliberately share the tag transport, but never the immutable signal acceptance.
|
|
386
|
+
// Only this exact namespace is mutable; e.g. `actor.opened.heartbeat.<id>` is still a signal.
|
|
387
|
+
const HEARTBEAT_NAME = /^([a-z0-9]+(?:[_-][a-z0-9]+)*)\.heartbeat\.([0-9a-f]{12})$/;
|
|
388
|
+
export const heartbeatName = (ref) => {
|
|
389
|
+
const m = ref.startsWith(TAGS) && HEARTBEAT_NAME.exec(ref.slice(TAGS.length));
|
|
390
|
+
return m ? { actor: m[1], genesis12: m[2] } : null;
|
|
391
|
+
};
|
|
392
|
+
export const heartbeatRef = (who, genesis) => {
|
|
393
|
+
if (!SHA.test(genesis))
|
|
394
|
+
C.fail('heartbeat requires a full room genesis');
|
|
395
|
+
const ref = `${TAGS}${who}.heartbeat.${genesis.slice(0, 12)}`;
|
|
396
|
+
if (!heartbeatName(ref))
|
|
397
|
+
C.fail('malformed heartbeat actor');
|
|
398
|
+
return ref;
|
|
399
|
+
};
|
|
400
|
+
const heartbeatBody = (text) => {
|
|
401
|
+
if (Buffer.byteLength(text) > 16384)
|
|
402
|
+
C.fail('heartbeat body exceeds 16 KiB');
|
|
403
|
+
const decoded = P.decodeText(Buffer.from(text));
|
|
404
|
+
if (decoded.invalid)
|
|
405
|
+
C.fail(`heartbeat body is ${decoded.invalid}`);
|
|
406
|
+
const lines = decoded.text.trimEnd().split('\n');
|
|
407
|
+
const fields = new Map();
|
|
408
|
+
for (const line of lines) {
|
|
409
|
+
const m = /^([A-Z][A-Za-z]*): (.+)$/.exec(line) ?? C.fail('heartbeat body must contain only facts as trailers');
|
|
410
|
+
if (fields.has(m[1]))
|
|
411
|
+
C.fail(`duplicate heartbeat field ${m[1]}`);
|
|
412
|
+
fields.set(m[1], m[2]);
|
|
413
|
+
}
|
|
414
|
+
const genesis = fields.get('Room') ?? C.fail('heartbeat must name its full room genesis');
|
|
415
|
+
if (!SHA.test(genesis))
|
|
416
|
+
C.fail('heartbeat must name its full room genesis');
|
|
417
|
+
if (!/^(working|waiting|gone)$/.test(fields.get('State') ?? ''))
|
|
418
|
+
C.fail('invalid heartbeat State');
|
|
419
|
+
if (!/^(yes|no|unknown)$/.test(fields.get('Alive') ?? ''))
|
|
420
|
+
C.fail('invalid heartbeat Alive');
|
|
421
|
+
const next = Number(fields.get('Next'));
|
|
422
|
+
if (!Number.isFinite(next) || next <= 0)
|
|
423
|
+
C.fail('heartbeat Next must be positive seconds');
|
|
424
|
+
return genesis;
|
|
425
|
+
};
|
|
426
|
+
const heartbeatObject = (H, ref, sha) => {
|
|
427
|
+
const s = heartbeatName(ref) ?? C.fail('malformed heartbeat namespace');
|
|
428
|
+
if (!SHA.test(sha) || git(H, ['cat-file', '-t', sha]).trim() !== 'tag')
|
|
429
|
+
C.fail('heartbeat must be an annotated tag');
|
|
430
|
+
if (Number(git(H, ['cat-file', '-s', sha])) > 32768)
|
|
431
|
+
C.fail('heartbeat object exceeds 32 KiB');
|
|
432
|
+
const decoded = P.decodeText(git(H, ['cat-file', 'tag', sha], { raw: true }));
|
|
433
|
+
if (decoded.invalid)
|
|
434
|
+
C.fail(`heartbeat tag is ${decoded.invalid}`);
|
|
435
|
+
const [header, ...rest] = decoded.text.split('\n\n');
|
|
436
|
+
const target = /^object ([0-9a-f]+)\ntype commit\ntag ([^\n]+)\ntagger ([^<\n]+) <[^\n]*> (\d+) [+-]\d{4}\n?$/.exec(header) ?? C.fail('malformed heartbeat tag headers');
|
|
437
|
+
if (!SHA.test(target[1]) || target[2] !== ref.slice(TAGS.length) || target[3].trim() !== s.actor)
|
|
438
|
+
C.fail('heartbeat tag name or actor disagrees');
|
|
439
|
+
const text = rest.join('\n\n').split('-----BEGIN SSH SIGNATURE-----')[0];
|
|
440
|
+
const genesis = heartbeatBody(text);
|
|
441
|
+
if (!genesis.startsWith(s.genesis12))
|
|
442
|
+
C.fail('heartbeat tag name and room genesis disagree');
|
|
443
|
+
return { ...s, genesis, target: target[1], time: Number(target[4]), text };
|
|
444
|
+
};
|
|
445
|
+
const checkHeartbeat = (H, R, ref, sha) => {
|
|
446
|
+
const s = heartbeatObject(H, ref, sha);
|
|
447
|
+
if (s.genesis !== R.genesis)
|
|
448
|
+
C.fail('heartbeat belongs to another room genesis');
|
|
449
|
+
const b = branch(H, R, rev(H, R.ref) ?? C.fail('heartbeat room has no branch here'));
|
|
450
|
+
// It is a replica's sync position, not a claim to author or execute the target message.
|
|
451
|
+
if (!b.control.includes(s.target) && !b.authors.has(s.target) && !b.skipped.has(s.target))
|
|
452
|
+
C.fail('heartbeat target is not on this room branch');
|
|
453
|
+
const signer = b.entries.find(e => e.principal === s.actor) ?? C.fail(`unadmitted heartbeat principal ${s.actor}`);
|
|
454
|
+
checkSignature(H, sha, s.actor, signer.key, 'tag');
|
|
455
|
+
};
|
|
456
|
+
export const verifyHeartbeat = (H, R, ref, sha) => {
|
|
457
|
+
if (!isRemote(H))
|
|
458
|
+
return null;
|
|
459
|
+
if (H.remoteAlarms?.[ref])
|
|
460
|
+
return H.remoteAlarms[ref];
|
|
461
|
+
if (H.heartbeatErrors?.[ref])
|
|
462
|
+
return H.heartbeatErrors[ref];
|
|
463
|
+
try {
|
|
464
|
+
checkHeartbeat(H, R, ref, sha);
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
catch (e) {
|
|
468
|
+
return reason(e);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
// Make an object without moving its ref. The caller performs local CAS before publishing with the same old OID.
|
|
472
|
+
export const makeHeartbeat = (H, R, who, target, text, home) => {
|
|
473
|
+
const ref = heartbeatRef(who, R.genesis);
|
|
474
|
+
if (!SHA.test(target))
|
|
475
|
+
C.fail('heartbeat target must be a full commit id');
|
|
476
|
+
if (/^Room:/m.test(text))
|
|
477
|
+
C.fail('heartbeat Room is transport metadata');
|
|
478
|
+
const body = `Room: ${R.genesis}\n${text.trimEnd()}\n`;
|
|
479
|
+
heartbeatBody(body);
|
|
480
|
+
const raw = `object ${target}\ntype commit\ntag ${ref.slice(TAGS.length)}\ntagger ${who} <${who}@ringi> ${Math.floor(Date.now() / 1000)} +0000\n\n${body}`;
|
|
481
|
+
const key = sign(H, who, home)[0]?.slice(2);
|
|
482
|
+
const signature = key ? command('ssh-keygen', ['-Y', 'sign', '-n', 'git', '-f', key], { input: raw }) : '';
|
|
483
|
+
const sha = git(H, ['mktag'], { input: raw + signature }).trim();
|
|
484
|
+
if (isRemote(H))
|
|
485
|
+
checkHeartbeat(H, R, ref, sha);
|
|
486
|
+
return sha;
|
|
487
|
+
};
|
|
385
488
|
const SIGNAL_NAME = /^([a-z0-9]+(?:[_-][a-z0-9]+)*)\.([a-z][a-z0-9.-]*)\.([0-9a-f]{12})$/;
|
|
386
489
|
const signalName = (ref) => {
|
|
490
|
+
if (heartbeatName(ref))
|
|
491
|
+
C.fail('heartbeat is not an immutable signal');
|
|
387
492
|
const m = ref.startsWith(TAGS) && SIGNAL_NAME.exec(ref.slice(TAGS.length));
|
|
388
493
|
if (!m)
|
|
389
494
|
return C.fail('malformed signal tag namespace');
|
|
@@ -479,6 +584,7 @@ const promote = (H, ref, sha) => {
|
|
|
479
584
|
};
|
|
480
585
|
const syncUnlocked = (H) => {
|
|
481
586
|
H.remoteAlarms = { ...pushAlarms(H) };
|
|
587
|
+
H.heartbeatErrors = {};
|
|
482
588
|
const url = origin(H);
|
|
483
589
|
if (!url) {
|
|
484
590
|
if (rev(H, 'refs/heads/main'))
|
|
@@ -523,7 +629,34 @@ const syncUnlocked = (H) => {
|
|
|
523
629
|
}
|
|
524
630
|
}
|
|
525
631
|
const tags = refs(H, TAG_TRACK), pinned = refs(H, TAG_ACCEPT);
|
|
632
|
+
// Include local beats to remove disappeared remote telemetry; unlike signals, nothing is pinned.
|
|
633
|
+
for (const ref of refs(H, TAGS).keys())
|
|
634
|
+
if (heartbeatName(ref) && !tags.has(TAG_TRACK + ref.slice(TAGS.length))) {
|
|
635
|
+
if (pinned.has(TAG_ACCEPT + ref.slice(TAGS.length)))
|
|
636
|
+
continue; // never waive previously accepted history
|
|
637
|
+
git(H, ['update-ref', '-d', ref]);
|
|
638
|
+
}
|
|
526
639
|
for (const name of new Set([...tags.keys()].map(r => r.slice(TAG_TRACK.length)).concat([...pinned.keys()].map(r => r.slice(TAG_ACCEPT.length))))) {
|
|
640
|
+
const beatRef = TAGS + name;
|
|
641
|
+
if (heartbeatName(beatRef)) {
|
|
642
|
+
if (pinned.has(TAG_ACCEPT + name)) {
|
|
643
|
+
H.remoteAlarms[beatRef] = 'heartbeat namespace overlaps a previously accepted signal; operator review required';
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
const sha = tags.get(TAG_TRACK + name) ?? C.fail('heartbeat is absent from the remote');
|
|
648
|
+
const b = heartbeatObject(H, beatRef, sha);
|
|
649
|
+
const R = byGenesis.get(b.genesis) ?? C.fail('heartbeat room is missing or quarantined');
|
|
650
|
+
checkHeartbeat(H, R, beatRef, sha);
|
|
651
|
+
git(H, ['update-ref', beatRef, sha]);
|
|
652
|
+
}
|
|
653
|
+
catch (e) {
|
|
654
|
+
H.heartbeatErrors[beatRef] = reason(e);
|
|
655
|
+
git(H, ['update-ref', '-d', beatRef]);
|
|
656
|
+
note(`heartbeat ${name}: ${reason(e)}; withheld`);
|
|
657
|
+
}
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
527
660
|
// Other tags, including the old room-scoped namespace, are not signals.
|
|
528
661
|
if (!SIGNAL_NAME.test(name))
|
|
529
662
|
continue;
|
|
@@ -665,6 +798,8 @@ const validateRef = (H, ref, sha) => {
|
|
|
665
798
|
// Publish one ref. With `behind`, a room's branch the remote has moved on is not an alarm: push returns false and the
|
|
666
799
|
// caller makes its commit again on the new tip.
|
|
667
800
|
export const push = (H, ref, { behind = false } = {}) => {
|
|
801
|
+
if (heartbeatName(ref))
|
|
802
|
+
C.fail('heartbeat publication requires its actor and expected object id (pushHeartbeat)');
|
|
668
803
|
if (!isRemote(H))
|
|
669
804
|
return true;
|
|
670
805
|
return locked(H, () => {
|
|
@@ -728,6 +863,41 @@ export const push = (H, ref, { behind = false } = {}) => {
|
|
|
728
863
|
return true;
|
|
729
864
|
});
|
|
730
865
|
};
|
|
866
|
+
// Publish only the actor's exact heartbeat with an explicit old OID. A racing beat is ordinary contention, never a
|
|
867
|
+
// signal rewrite alarm. Neither success nor failure changes immutable acceptance pins or budget/history state.
|
|
868
|
+
export const pushHeartbeat = (H, R, who, sha, expected) => {
|
|
869
|
+
const ref = heartbeatRef(who, R.genesis);
|
|
870
|
+
if (!SHA.test(sha) || (expected !== null && !SHA.test(expected)))
|
|
871
|
+
C.fail('heartbeat publication requires full object ids');
|
|
872
|
+
if (!isRemote(H))
|
|
873
|
+
return true;
|
|
874
|
+
return locked(H, () => {
|
|
875
|
+
if (object(H, ref) !== sha)
|
|
876
|
+
return false; // a newer local beat already won
|
|
877
|
+
if (object(H, TAG_ACCEPT + ref.slice(TAGS.length)))
|
|
878
|
+
C.fail('heartbeat namespace overlaps previously accepted signal history');
|
|
879
|
+
checkHeartbeat(H, R, ref, sha);
|
|
880
|
+
const url = origin(H);
|
|
881
|
+
if (!url)
|
|
882
|
+
return true;
|
|
883
|
+
checkUrl(url);
|
|
884
|
+
const r = git(H, ['push', '-q', '--porcelain', `--force-with-lease=${ref}:${expected ?? ''}`, url, `${sha}:${ref}`], { ok: true });
|
|
885
|
+
if (r.status !== 0) {
|
|
886
|
+
// A lost acknowledgement is safe to reconcile: equality proves no second beat is needed.
|
|
887
|
+
const observed = git(H, ['ls-remote', '--refs', url, ref], { ok: true });
|
|
888
|
+
const got = observed.status === 0 ? observed.stdout.trim().split(/\s+/) : [];
|
|
889
|
+
if (got[0] !== sha || got[1] !== ref) {
|
|
890
|
+
if (/\[rejected\].*\(stale info\)/.test(r.stdout))
|
|
891
|
+
return false;
|
|
892
|
+
C.fail(`heartbeat push failed for ${ref.slice(TAGS.length)}: ${String(r.stderr || r.stdout || 'transport error').trim().split('\n').at(-1)}`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
git(H, ['update-ref', TAG_TRACK + ref.slice(TAGS.length), sha]);
|
|
896
|
+
if (H.heartbeatErrors)
|
|
897
|
+
delete H.heartbeatErrors[ref];
|
|
898
|
+
return true;
|
|
899
|
+
});
|
|
900
|
+
};
|
|
731
901
|
// Only the operator command calls this after reviewing a room's transport alarm: the room's branch as the remote has
|
|
732
902
|
// it becomes the accepted baseline, and the displaced local branch is kept as evidence. Nothing is waived: the branch
|
|
733
903
|
// must verify, and its invalid commits stay skipped.
|