@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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -1
  3. package/src/main/resources/skill/ringi/SKILL.md +6 -3
  4. package/target/dts/app/agent.d.ts +5 -0
  5. package/target/dts/app/heartbeat.d.ts +33 -0
  6. package/target/dts/app/room.d.ts +5 -0
  7. package/target/dts/app/rooms.d.ts +2 -1
  8. package/target/dts/app/session.d.ts +6 -1
  9. package/target/dts/cli/operator.d.ts +2 -0
  10. package/target/dts/cli/shared.d.ts +7 -0
  11. package/target/dts/heartbeat/index.d.ts +35 -0
  12. package/target/dts/heartbeat/platform.d.ts +15 -0
  13. package/target/dts/heartbeat/tools.d.ts +11 -0
  14. package/target/dts/hub/index.d.ts +6 -0
  15. package/target/dts/protocol/files.d.ts +1 -0
  16. package/target/dts/runtime/reader.d.ts +7 -0
  17. package/target/dts/store/git/index.d.ts +14 -0
  18. package/target/dts/store/git/remote.d.ts +9 -0
  19. package/target/dts/store/index.d.ts +12 -0
  20. package/target/esm/app/agent.js +3 -1
  21. package/target/esm/app/heartbeat.js +110 -0
  22. package/target/esm/app/room.js +55 -1
  23. package/target/esm/app/rooms.js +7 -3
  24. package/target/esm/app/session.js +4 -2
  25. package/target/esm/cli/agent.js +8 -2
  26. package/target/esm/cli/args.js +2 -2
  27. package/target/esm/cli/index.js +13 -6
  28. package/target/esm/cli/operator.js +39 -5
  29. package/target/esm/cli/session.js +14 -3
  30. package/target/esm/cli/shared.js +17 -1
  31. package/target/esm/heartbeat/index.js +41 -0
  32. package/target/esm/heartbeat/platform.js +83 -0
  33. package/target/esm/heartbeat/tools.js +68 -0
  34. package/target/esm/hub/index.js +13 -1
  35. package/target/esm/runtime/watch.js +11 -1
  36. package/target/esm/store/git/index.js +82 -6
  37. 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
- runGit(['init', '--bare', '-q', '-b', 'main', dir], { where: home });
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
- runGit(['init', '--bare', '-q', '-b', 'main', dir], { where: home });
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 => !n.includes('/') && n !== 'main' && n.includes('@') === archived);
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
- return m ? [{ ref, actor: m[1], name: m[2], object, type }] : [];
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
- out.set(sha, { target, tagger: /^tagger (.*?) <[^>\n]*> \d+ [+-]\d{4}$/m.exec(hdr)?.[1] ?? '', text });
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.