@idleware/ringi 0.4.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/target/dts/app/heartbeat.d.ts +33 -0
- package/target/dts/app/room.d.ts +2 -1
- package/target/dts/cli/operator.d.ts +2 -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/store/git/index.d.ts +14 -1
- package/target/dts/store/git/remote.d.ts +9 -0
- package/target/dts/store/index.d.ts +12 -1
- package/target/esm/app/heartbeat.js +110 -0
- package/target/esm/app/room.js +31 -13
- package/target/esm/cli/args.js +1 -1
- package/target/esm/cli/index.js +7 -1
- package/target/esm/cli/operator.js +28 -0
- package/target/esm/cli/session.js +7 -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/store/git/index.js +81 -6
- package/target/esm/store/git/remote.js +170 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as HB from '../heartbeat/index.ts';
|
|
2
|
+
import type { Room } from '../hub/index.ts';
|
|
3
|
+
import type { Session } from '../runtime/session.ts';
|
|
4
|
+
export type Pulse = {
|
|
5
|
+
session: Session;
|
|
6
|
+
tool: string;
|
|
7
|
+
beat: HB.Beat;
|
|
8
|
+
};
|
|
9
|
+
export declare const heartbeats: ({ sample }?: {
|
|
10
|
+
sample?: number;
|
|
11
|
+
}) => Promise<Pulse[]>;
|
|
12
|
+
export declare const ensurePulse: (cli: string) => void;
|
|
13
|
+
export type Beaten = {
|
|
14
|
+
session: Session;
|
|
15
|
+
beat?: HB.Beat;
|
|
16
|
+
error?: string;
|
|
17
|
+
};
|
|
18
|
+
export declare const beatAll: (prev: Map<string, HB.Facts>, next: number) => Beaten[];
|
|
19
|
+
export declare const pulse: ({ every, once, log }: {
|
|
20
|
+
every?: number;
|
|
21
|
+
once?: boolean;
|
|
22
|
+
log: (s: string) => void;
|
|
23
|
+
}) => Promise<number>;
|
|
24
|
+
export declare const published: (R: Room) => {
|
|
25
|
+
agent: string;
|
|
26
|
+
target: import("../index.ts").Rev;
|
|
27
|
+
time: number;
|
|
28
|
+
text: string;
|
|
29
|
+
state: string;
|
|
30
|
+
waits: string | null;
|
|
31
|
+
age: number;
|
|
32
|
+
overdue: boolean;
|
|
33
|
+
}[];
|
package/target/dts/app/room.d.ts
CHANGED
|
@@ -28,9 +28,10 @@ export type Sent = {
|
|
|
28
28
|
to: string[];
|
|
29
29
|
files: number;
|
|
30
30
|
};
|
|
31
|
+
export declare const FILE_MAX: number;
|
|
31
32
|
export declare const staged: ({ R, me }: Actor) => Staged[];
|
|
32
33
|
export declare const stage: ({ R, me }: Actor, files: Entry[]) => Staged[];
|
|
33
|
-
export declare const unstage: (
|
|
34
|
+
export declare const unstage: (a: Actor, paths: string[] | null) => Staged[];
|
|
34
35
|
export declare const send: ({ R, me }: Actor, x: SendInput) => Sent;
|
|
35
36
|
export declare const marks: (R: Room, all: All) => Map<string, Record<string, string[]>>;
|
|
36
37
|
export declare const message: ({ R, me }: Actor, ref: string) => {
|
|
@@ -2,6 +2,8 @@ import type { SessionView } from '../app/overview.ts';
|
|
|
2
2
|
import type { Opts } from './args.ts';
|
|
3
3
|
export declare const sessionLine: (s: SessionView) => string;
|
|
4
4
|
export declare const where: (o: Opts) => Promise<void>;
|
|
5
|
+
export declare const heartbeats: (o: Opts) => Promise<void>;
|
|
6
|
+
export declare const pulse: (o: Opts) => Promise<void>;
|
|
5
7
|
export declare const sessions: (o: Opts) => void;
|
|
6
8
|
export declare const forget: (o: Opts) => void;
|
|
7
9
|
export declare const hub: (o: Opts) => void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type Facts = {
|
|
2
|
+
at: number;
|
|
3
|
+
alive: boolean | null;
|
|
4
|
+
cpu: number | null;
|
|
5
|
+
trace: number | null;
|
|
6
|
+
seen: number;
|
|
7
|
+
stopped: boolean;
|
|
8
|
+
waits: string | null;
|
|
9
|
+
};
|
|
10
|
+
export type State = 'working' | 'waiting' | 'gone';
|
|
11
|
+
export type Beat = {
|
|
12
|
+
state: State;
|
|
13
|
+
waits: string | null;
|
|
14
|
+
alive: boolean | null;
|
|
15
|
+
quiet: number;
|
|
16
|
+
cpu: number | null;
|
|
17
|
+
next: number;
|
|
18
|
+
};
|
|
19
|
+
export type SessionLike = {
|
|
20
|
+
handle: string;
|
|
21
|
+
product: string | null;
|
|
22
|
+
pid: number | null;
|
|
23
|
+
lastSeen: string;
|
|
24
|
+
state: string;
|
|
25
|
+
};
|
|
26
|
+
export declare const probe: (s: SessionLike, waits?: string | null, rows?: Map<number, {
|
|
27
|
+
ppid: number;
|
|
28
|
+
cpu: number;
|
|
29
|
+
}> | null) => Facts;
|
|
30
|
+
export declare const tracedBy: (s: Pick<SessionLike, 'handle' | 'product'>) => string;
|
|
31
|
+
export declare const judge: (cur: Facts, prev?: Facts | null, { active, next }?: {
|
|
32
|
+
active?: number;
|
|
33
|
+
next?: number;
|
|
34
|
+
}) => Beat;
|
|
35
|
+
export declare const body: (b: Beat) => string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const SUPPORTED: boolean;
|
|
2
|
+
export declare const alive: (pid: number) => boolean;
|
|
3
|
+
export declare const seconds: (time: string) => number;
|
|
4
|
+
export declare const procStat: (line: string) => {
|
|
5
|
+
ppid: number;
|
|
6
|
+
cpu: number;
|
|
7
|
+
} | null;
|
|
8
|
+
export declare const table: () => Map<number, {
|
|
9
|
+
ppid: number;
|
|
10
|
+
cpu: number;
|
|
11
|
+
}> | null;
|
|
12
|
+
export declare const treeCpu: (pid: number, rows?: Map<number, {
|
|
13
|
+
ppid: number;
|
|
14
|
+
cpu: number;
|
|
15
|
+
}> | null) => number | null;
|
|
@@ -15,6 +15,8 @@ export declare class GitStore implements Store {
|
|
|
15
15
|
readonly home: string;
|
|
16
16
|
readonly h: Remote.Handle;
|
|
17
17
|
constructor(hubDir: string, d: Descriptor, home: string);
|
|
18
|
+
private openable;
|
|
19
|
+
private checkout;
|
|
18
20
|
private args;
|
|
19
21
|
private git;
|
|
20
22
|
private gitOk;
|
|
@@ -57,7 +59,11 @@ export declare class GitStore implements Store {
|
|
|
57
59
|
logIds(room: StoredRoom): string[];
|
|
58
60
|
logHead(room: StoredRoom, id: string): string | null;
|
|
59
61
|
messages(room: StoredRoom, id: string, head: string): StoredMessage[];
|
|
60
|
-
blob(file: string):
|
|
62
|
+
blob(file: string): {
|
|
63
|
+
id: string;
|
|
64
|
+
size: number;
|
|
65
|
+
};
|
|
66
|
+
retain(room: StoredRoom, who: string, entries: Entry[]): void;
|
|
61
67
|
append(room: StoredRoom, who: string, text: string, entries: Entry[]): string;
|
|
62
68
|
private onTop;
|
|
63
69
|
verifyLog(room: StoredRoom, id: string, head: string): string | null;
|
|
@@ -65,6 +71,13 @@ export declare class GitStore implements Store {
|
|
|
65
71
|
unpin(room: StoredRoom, reader: string, id: string): void;
|
|
66
72
|
file(rev: string, p: string): Buffer;
|
|
67
73
|
sizes(rev: string, paths: string[]): Map<string, number>;
|
|
74
|
+
heartbeat(room: StoredRoom, who: string, target: string, text: string): string;
|
|
75
|
+
heartbeats(room: StoredRoom): Array<{
|
|
76
|
+
agent: string;
|
|
77
|
+
target: string;
|
|
78
|
+
time: number;
|
|
79
|
+
text: string;
|
|
80
|
+
}>;
|
|
68
81
|
signal(room: StoredRoom, who: string, target: string, name: string): void;
|
|
69
82
|
private tags;
|
|
70
83
|
private tagObjects;
|
|
@@ -6,6 +6,7 @@ export type Handle = {
|
|
|
6
6
|
d: Descriptor;
|
|
7
7
|
main?: string;
|
|
8
8
|
remoteAlarms?: Record<string, string>;
|
|
9
|
+
heartbeatErrors?: Record<string, string>;
|
|
9
10
|
bootstrap?: boolean;
|
|
10
11
|
};
|
|
11
12
|
export type Room = {
|
|
@@ -65,6 +66,13 @@ export declare const readCommits: (H: Handle, shas: string[]) => Raw[];
|
|
|
65
66
|
export declare const branch: (H: Handle, R: Pick<Room, 'name' | 'ref' | 'genesis'>, tip: string) => Branch;
|
|
66
67
|
export declare const verifyRoom: (H: Handle, R: Room) => VerifiedRoom;
|
|
67
68
|
export declare const verify: (H: Handle, R: Room, who: string, head: string) => string | null;
|
|
69
|
+
export declare const heartbeatName: (ref: string) => {
|
|
70
|
+
actor: string;
|
|
71
|
+
genesis12: string;
|
|
72
|
+
} | null;
|
|
73
|
+
export declare const heartbeatRef: (who: string, genesis: string) => string;
|
|
74
|
+
export declare const verifyHeartbeat: (H: Handle, R: Room, ref: string, sha: string) => string | null;
|
|
75
|
+
export declare const makeHeartbeat: (H: Handle, R: Room, who: string, target: string, text: string, home: string) => string;
|
|
68
76
|
export declare const verifySignal: (H: Handle, R: Room, ref: string, sha: string) => string | null;
|
|
69
77
|
export declare const sync: (H: Handle) => Handle;
|
|
70
78
|
export declare const hubAdd: ({ home, name, url, root, genesis }: {
|
|
@@ -83,4 +91,5 @@ export declare const roomAdmit: (H: Handle, R: Room, who: string, publicKey: str
|
|
|
83
91
|
export declare const push: (H: Handle, ref: string, { behind }?: {
|
|
84
92
|
behind?: boolean;
|
|
85
93
|
}) => boolean;
|
|
94
|
+
export declare const pushHeartbeat: (H: Handle, R: Room, who: string, sha: string, expected: string | null) => boolean;
|
|
86
95
|
export declare const forget: (H: Handle, R: Room) => void;
|
|
@@ -75,7 +75,11 @@ export interface Store {
|
|
|
75
75
|
extends(older: Rev, newer: Rev): boolean;
|
|
76
76
|
messages(room: StoredRoom, id: string, head: Rev): StoredMessage[];
|
|
77
77
|
append(room: StoredRoom, who: string, text: string, entries: Entry[]): Rev;
|
|
78
|
-
blob(file: string):
|
|
78
|
+
blob(file: string): {
|
|
79
|
+
id: Rev;
|
|
80
|
+
size: number;
|
|
81
|
+
};
|
|
82
|
+
retain(room: StoredRoom, who: string, entries: Entry[]): void;
|
|
79
83
|
alarms?(room: StoredRoom): string[];
|
|
80
84
|
verifyLog(room: StoredRoom, id: string, head: Rev): string | null;
|
|
81
85
|
pin(room: StoredRoom, reader: string, id: string, rev: Rev): void;
|
|
@@ -84,6 +88,13 @@ export interface Store {
|
|
|
84
88
|
sizes(rev: Rev, paths: string[]): Map<string, number>;
|
|
85
89
|
signal(room: StoredRoom, who: string, target: Rev, name: string): void;
|
|
86
90
|
signals(room: StoredRoom): SignalRecord[];
|
|
91
|
+
heartbeat?(room: StoredRoom, who: string, target: Rev, text: string): Rev;
|
|
92
|
+
heartbeats?(room: StoredRoom): Array<{
|
|
93
|
+
agent: string;
|
|
94
|
+
target: Rev;
|
|
95
|
+
time: number;
|
|
96
|
+
text: string;
|
|
97
|
+
}>;
|
|
87
98
|
admit?(room: StoredRoom, who: string, key: string): Rev;
|
|
88
99
|
joinKey?(room: StoredRoom, who: string): JoinKey;
|
|
89
100
|
forgetLog?(room: StoredRoom, id: string): void;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// heartbeat: the pulse of every session this machine holds, judged from its process and its tool's trace without the
|
|
2
|
+
// model (heartbeat/). It reads session records only: it never binds a session, renews its lease or marks anything read.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import * as HB from '../heartbeat/index.js';
|
|
7
|
+
import { alive, table } from '../heartbeat/platform.js';
|
|
8
|
+
import { HOME, locked } from '../home/index.js';
|
|
9
|
+
import { openHub, openRoom, start } from '../hub/index.js';
|
|
10
|
+
import { sleep } from '../lib/index.js';
|
|
11
|
+
import { allSessions } from '../runtime/session.js';
|
|
12
|
+
// What the room keeps the session waiting for, if anything; a hub that cannot be read says nothing.
|
|
13
|
+
const roomWaits = (s) => {
|
|
14
|
+
try {
|
|
15
|
+
const R = openRoom(openHub(s.hub), s.room, { quiet: true });
|
|
16
|
+
return R && R.genesis === s.genesis && !start(R).started ? 'quorum' : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
// Two looks `sample` seconds apart when a watcher is among them, so the CPU its turn spent shows.
|
|
23
|
+
export const heartbeats = async ({ sample = 1 } = {}) => {
|
|
24
|
+
const ss = allSessions();
|
|
25
|
+
const waits = ss.map(roomWaits);
|
|
26
|
+
const first = ss.map((s, i) => HB.probe(s, waits[i], table()));
|
|
27
|
+
if (ss.some(s => s.product === 'watch'))
|
|
28
|
+
await sleep(sample * 1000);
|
|
29
|
+
const rows = table();
|
|
30
|
+
return ss.map((s, i) => ({ session: s, tool: HB.tracedBy(s), beat: HB.judge(HB.probe(s, waits[i], rows), first[i]) }));
|
|
31
|
+
};
|
|
32
|
+
// --- pulse: the process that beats for every session here, with no model in it ---
|
|
33
|
+
const PIDFILE = () => path.join(HOME, 'pulse.pid');
|
|
34
|
+
const running = () => {
|
|
35
|
+
try {
|
|
36
|
+
const pid = Number(fs.readFileSync(PIDFILE(), 'utf8'));
|
|
37
|
+
return pid && pid !== process.pid && alive(pid) ? pid : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
// Starts the pulse in the background unless one runs (or RINGI_PULSE=off): join and continue call it.
|
|
44
|
+
export const ensurePulse = (cli) => {
|
|
45
|
+
if (process.env.RINGI_PULSE === 'off' || running())
|
|
46
|
+
return;
|
|
47
|
+
spawn(process.execPath, [cli, 'pulse'], { detached: true, stdio: 'ignore', env: process.env }).unref();
|
|
48
|
+
};
|
|
49
|
+
export const beatAll = (prev, next) => {
|
|
50
|
+
const rows = table();
|
|
51
|
+
return allSessions().flatMap((s) => {
|
|
52
|
+
try {
|
|
53
|
+
const H = openHub(s.hub);
|
|
54
|
+
const R = openRoom(H, s.room, { quiet: true });
|
|
55
|
+
// A gone room: the session leaves it on its own; nothing to beat for.
|
|
56
|
+
if (!R || R.genesis !== s.genesis || !H.store.heartbeat)
|
|
57
|
+
return [];
|
|
58
|
+
const f = HB.probe(s, start(R).started ? null : 'quorum', rows);
|
|
59
|
+
const b = HB.judge(f, prev.get(s.handle) ?? null, { next });
|
|
60
|
+
prev.set(s.handle, f);
|
|
61
|
+
H.store.heartbeat(R.s, s.agent, R.s.tip, HB.body(b));
|
|
62
|
+
return [{ session: s, beat: b }];
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
return [{ session: s, error: e.message }];
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
// The loop: one pulse per home, beating every `every` seconds until no session is left or the home is gone.
|
|
70
|
+
export const pulse = async ({ every = 30, once = false, log }) => {
|
|
71
|
+
// Claimed under the home's lock, so two starts at once leave one pulse.
|
|
72
|
+
const other = locked('pulse', () => {
|
|
73
|
+
const pid = running();
|
|
74
|
+
if (!pid)
|
|
75
|
+
fs.writeFileSync(PIDFILE(), String(process.pid));
|
|
76
|
+
return pid;
|
|
77
|
+
});
|
|
78
|
+
if (other) {
|
|
79
|
+
log(`pulse: already running (pid ${other})`);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
const prev = new Map();
|
|
83
|
+
try {
|
|
84
|
+
for (let idle = 0; idle < 2;) {
|
|
85
|
+
const beats = beatAll(prev, every);
|
|
86
|
+
for (const b of beats)
|
|
87
|
+
if (b.error)
|
|
88
|
+
log(`pulse: ${b.session.agent} in ${b.session.hub}/${b.session.room}: ${b.error}`);
|
|
89
|
+
idle = beats.length ? 0 : idle + 1;
|
|
90
|
+
if (once || !fs.existsSync(HOME))
|
|
91
|
+
break;
|
|
92
|
+
await sleep(every * 1000);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
try {
|
|
97
|
+
if (Number(fs.readFileSync(PIDFILE(), 'utf8')) === process.pid)
|
|
98
|
+
fs.rmSync(PIDFILE());
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
};
|
|
104
|
+
// What the room's published heartbeats say, as an orchestrator would read them: whose, how old, overdue or not.
|
|
105
|
+
export const published = (R) => (R.hub.store.heartbeats?.(R.s) ?? []).map(h => {
|
|
106
|
+
const next = Number(/^Next: (\d+)$/m.exec(h.text)?.[1] ?? NaN);
|
|
107
|
+
const age = Math.max(0, Math.round(Date.now() / 1000 - h.time));
|
|
108
|
+
return { ...h, state: /^State: (\w+)$/m.exec(h.text)?.[1] ?? '?', waits: /^Waits: (.+)$/m.exec(h.text)?.[1] ?? null, age, overdue: Number.isFinite(next) && age > next * 2 };
|
|
109
|
+
});
|
|
110
|
+
//# sourceMappingURL=heartbeat.js.map
|
package/target/esm/app/room.js
CHANGED
|
@@ -14,11 +14,23 @@ import { linked, loadRoomUnlocked, loadState, readerLock, saveState } from '../r
|
|
|
14
14
|
import { taskView } from './view.js';
|
|
15
15
|
export const isRef = (R) => (x) => R.hub.store.isRef(x);
|
|
16
16
|
// Staging, like git's index: add takes a file's content into the store now, and the actor's next message carries it,
|
|
17
|
-
// whatever happens to the file meanwhile. Results and state travel only so, never through a shared directory.
|
|
17
|
+
// whatever happens to the file meanwhile. Results and state travel only so, never through a shared directory. The store
|
|
18
|
+
// keeps staged contents reachable (`retain`); it grows before the list does and shrinks after, so a crash in between
|
|
19
|
+
// leaks an object rather than loses one.
|
|
20
|
+
export const FILE_MAX = 64 << 20;
|
|
21
|
+
const tooBig = (path, size) => size > FILE_MAX && fail(`${path} is ${Math.ceil(size / (1 << 20))} MiB; an attachment is at most ${FILE_MAX >> 20} MiB`);
|
|
22
|
+
// What retain keeps: each staged content once, named by its id, so two snapshots of one path both stay.
|
|
23
|
+
const kept = (all) => [...new Set(all.map(s => s.blob))].map(blob => ({ path: blob, blob, mode: '100644' }));
|
|
18
24
|
export const staged = ({ R, me }) => loadState(R, me).staged ?? [];
|
|
19
25
|
export const stage = ({ R, me }, files) => {
|
|
20
|
-
const taken = files.map(e =>
|
|
21
|
-
|
|
26
|
+
const taken = files.map(e => {
|
|
27
|
+
if (!e.file)
|
|
28
|
+
fail(`${e.path}: add takes files`);
|
|
29
|
+
tooBig(e.path, fs.statSync(e.file).size);
|
|
30
|
+
const b = R.hub.store.blob(e.file);
|
|
31
|
+
tooBig(e.path, b.size);
|
|
32
|
+
return { path: e.path, blob: b.id, mode: e.mode, size: b.size };
|
|
33
|
+
});
|
|
22
34
|
return readerLock(R, me, () => {
|
|
23
35
|
const st = loadState(R, me);
|
|
24
36
|
const next = new Map((st.staged ?? []).map(s => [s.path, s]));
|
|
@@ -26,19 +38,25 @@ export const stage = ({ R, me }, files) => {
|
|
|
26
38
|
next.set(s.path, s);
|
|
27
39
|
const all = [...next.values()];
|
|
28
40
|
checkCollisions(all.map(s => s.path));
|
|
41
|
+
R.hub.store.retain(R.s, me, kept([...all, ...st.staged ?? []]));
|
|
29
42
|
saveState(R, me, { ...st, staged: all });
|
|
43
|
+
R.hub.store.retain(R.s, me, kept(all));
|
|
30
44
|
return all;
|
|
31
45
|
});
|
|
32
46
|
};
|
|
33
|
-
|
|
34
|
-
export const unstage = ({ R, me }, paths) => readerLock(R, me, () => {
|
|
47
|
+
const shrink = ({ R, me }, keep) => {
|
|
35
48
|
const st = loadState(R, me);
|
|
36
|
-
const
|
|
37
|
-
if (missing.length)
|
|
38
|
-
fail(`not staged: ${missing.join(', ')}`);
|
|
39
|
-
const left = paths ? (st.staged ?? []).filter(s => !paths.includes(s.path)) : [];
|
|
49
|
+
const left = (st.staged ?? []).filter(keep);
|
|
40
50
|
saveState(R, me, { ...st, staged: left });
|
|
51
|
+
R.hub.store.retain(R.s, me, kept(left));
|
|
41
52
|
return left;
|
|
53
|
+
};
|
|
54
|
+
// Unstages the given paths, or everything.
|
|
55
|
+
export const unstage = (a, paths) => readerLock(a.R, a.me, () => {
|
|
56
|
+
const missing = (paths ?? []).filter(p => !staged(a).some(s => s.path === p));
|
|
57
|
+
if (missing.length)
|
|
58
|
+
fail(`not staged: ${missing.join(', ')}`);
|
|
59
|
+
return shrink(a, s => paths !== null && !paths.includes(s.path));
|
|
42
60
|
});
|
|
43
61
|
// A message from the actor. The operator may omit `to`: the room, or the assignees when there are any.
|
|
44
62
|
export const send = ({ R, me }, x) => {
|
|
@@ -80,6 +98,9 @@ export const send = ({ R, me }, x) => {
|
|
|
80
98
|
}
|
|
81
99
|
// What the actor staged goes along, an explicit attachment of the same path in its place.
|
|
82
100
|
const stagedNow = staged({ R, me }), explicit = x.files ?? [];
|
|
101
|
+
for (const e of explicit)
|
|
102
|
+
if (e.file)
|
|
103
|
+
tooBig(e.path, fs.statSync(e.file).size);
|
|
83
104
|
const named = new Set(explicit.map(e => e.path));
|
|
84
105
|
const entries = [...stagedNow.filter(s => !named.has(s.path)).map(s => ({ path: s.path, blob: s.blob, mode: s.mode })), ...explicit];
|
|
85
106
|
checkCollisions(entries.map(e => e.path));
|
|
@@ -90,10 +111,7 @@ export const send = ({ R, me }, x) => {
|
|
|
90
111
|
checkOutgoing(text, isRef(R));
|
|
91
112
|
const id = append(R, me, text, entries);
|
|
92
113
|
if (stagedNow.length)
|
|
93
|
-
readerLock(R, me, () => {
|
|
94
|
-
const st = loadState(R, me);
|
|
95
|
-
saveState(R, me, { ...st, staged: (st.staged ?? []).filter(s => !stagedNow.some(t => t.path === s.path && t.blob === s.blob)) });
|
|
96
|
-
});
|
|
114
|
+
readerLock(R, me, () => shrink({ R, me }, s => !stagedNow.some(t => t.path === s.path && t.blob === s.blob)));
|
|
97
115
|
if (to.length === 1 && to[0] === '*')
|
|
98
116
|
note(`addressed to the whole room: this ${x.task ? P.opens : 'message'} wakes nobody; name recipients (--to or --assign) to start work`);
|
|
99
117
|
return { id, to, files: entries.length };
|
package/target/esm/cli/args.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fail } from '../lib/index.js';
|
|
|
5
5
|
import { checkPath } from '../app/room.js';
|
|
6
6
|
export const out = (s = '') => { process.stdout.write(s.endsWith('\n') ? s : `${s}\n`); };
|
|
7
7
|
export const dump = (v) => out(JSON.stringify(v, null, 2));
|
|
8
|
-
const BOOLS = new Set(['task', 'peek', 'all', 'json', 'help', 'once', 'project', 'follow', 'short', 'yes', 'require-grant', 'md', 'archived', 'signed', 'repair', 'handoff', 'clear']);
|
|
8
|
+
const BOOLS = new Set(['task', 'peek', 'all', 'json', 'help', 'once', 'project', 'follow', 'short', 'yes', 'require-grant', 'md', 'archived', 'signed', 'repair', 'handoff', 'clear', 'published']);
|
|
9
9
|
const MULTI = new Set(['m', 'attach', 'set', 'drop']);
|
|
10
10
|
const SHORT = { m: 'm', F: 'file', n: 'n', o: 'out', h: 'help' };
|
|
11
11
|
export const parseArgs = (argv) => {
|
package/target/esm/cli/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { flag, opt, out, parseArgs } from './args.js';
|
|
|
6
6
|
import { watch } from '../app/agent.js';
|
|
7
7
|
import { card, inbox, ping, take, wait, whoami } from './agent.js';
|
|
8
8
|
import { operatorCtx } from './context.js';
|
|
9
|
-
import { forget, hub, roomCmd, sessions, where } from './operator.js';
|
|
9
|
+
import { forget, heartbeats, hub, pulse, roomCmd, sessions, where } from './operator.js';
|
|
10
10
|
import { join, leave, resume, stop } from './session.js';
|
|
11
11
|
import { add, cat, decide, get, grants, log, profileCmd, proposals, propose, read, reply, send, signals, stamp_, tasks, thread, who } from './shared.js';
|
|
12
12
|
import { skill } from './skill.js';
|
|
@@ -38,6 +38,8 @@ export const TABLE = [
|
|
|
38
38
|
['signals [--all] [--repair]', 'both', 'what happened (signals) and where attention is (read and taken marks), each checked; --repair restores your missing signals'],
|
|
39
39
|
['profile', 'both', 'the room\'s profile: what a piece of work is, its states, who may move it'],
|
|
40
40
|
['sessions | sessions forget <handle>', 'operator', 'the chats the CLI remembers; drop a stale one'],
|
|
41
|
+
['heartbeats [--json] | heartbeats --published [--room <id>]', 'operator', 'each session\'s pulse on this machine, from its process and its tool\'s trace, without the model: working, waiting (for what) or gone; --published: the room\'s heartbeat tags as an orchestrator reads them'],
|
|
42
|
+
['pulse [--beat <s>] [--once]', 'operator', 'beat for every session here: sync its hub, put its heartbeat tag on the last pulled commit of its room; join and continue start it (RINGI_PULSE=off: not)'],
|
|
41
43
|
['forget <room>/<log>', 'operator', 'after a changed-history alarm: accept a new baseline for that log'],
|
|
42
44
|
['watch [<hub>:]<room>/<agent> --exec <cmd> [--handoff] [--scope <text>] [--once] [--require-grant] [--from <ids>] [--max-hop n] [--max-runs n] [--window s] [--interval s]', 'operator', 'headless turns for one agent: answers pings, spends grants, writes usage receipts; --handoff, run from the agent\'s own chat, moves its seat and scope to the watcher; exits with the failed turn\'s status'],
|
|
43
45
|
['skill install [--project] | uninstall | status | show', 'operator', 'the skill for Claude Code, Codex and Cursor'],
|
|
@@ -99,6 +101,10 @@ export const main = async (argv = process.argv.slice(2)) => {
|
|
|
99
101
|
return where(o);
|
|
100
102
|
if (cmd === 'sessions')
|
|
101
103
|
return sessions(o);
|
|
104
|
+
if (cmd === 'heartbeats')
|
|
105
|
+
return heartbeats(o);
|
|
106
|
+
if (cmd === 'pulse')
|
|
107
|
+
return pulse(o);
|
|
102
108
|
if (cmd === 'forget')
|
|
103
109
|
return forget(o);
|
|
104
110
|
if (cmd === 'watch') {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// arguments, calls app operations and prints the result.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import * as Hubs from '../app/hubs.js';
|
|
5
|
+
import * as Heartbeat from '../app/heartbeat.js';
|
|
5
6
|
import * as Overview from '../app/overview.js';
|
|
6
7
|
import * as Rooms from '../app/rooms.js';
|
|
7
8
|
import { oneLine, render } from '../app/view.js';
|
|
@@ -42,6 +43,33 @@ export const where = async (o) => {
|
|
|
42
43
|
show(now.all, m);
|
|
43
44
|
}
|
|
44
45
|
};
|
|
46
|
+
// Each session's pulse on this machine, judged without the model.
|
|
47
|
+
export const heartbeats = async (o) => {
|
|
48
|
+
if (flag(o, 'published')) {
|
|
49
|
+
const R = Rooms.roomIn(Hubs.hubOf(opt(o, 'hub')), opt(o, 'room'));
|
|
50
|
+
const hs = Heartbeat.published(R);
|
|
51
|
+
if (flag(o, 'json'))
|
|
52
|
+
return dump(hs);
|
|
53
|
+
if (!hs.length)
|
|
54
|
+
return out(`no heartbeats in ${R.name}`);
|
|
55
|
+
for (const h of hs)
|
|
56
|
+
out(`${h.agent.padEnd(12)} ${(h.waits ? `${h.state} (${h.waits})` : h.state).padEnd(22)} ${h.age}s ago${h.overdue ? ', overdue' : ''} on ${short(h.target)}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const ps = await Heartbeat.heartbeats();
|
|
60
|
+
if (flag(o, 'json'))
|
|
61
|
+
return dump(ps);
|
|
62
|
+
if (!ps.length)
|
|
63
|
+
return out('no sessions');
|
|
64
|
+
for (const { session: s, tool, beat: b } of ps) {
|
|
65
|
+
const state = b.waits ? `${b.state} (${b.waits})` : b.state;
|
|
66
|
+
out(`${s.agent.padEnd(12)} ${`${s.hub}/${s.room}`.padEnd(24)} ${state.padEnd(22)} alive ${b.alive === null ? '?' : b.alive ? 'yes' : 'no'} quiet ${b.quiet}s${b.cpu === null ? '' : ` cpu ${b.cpu.toFixed(1)}s`} ${tool} ${s.handle}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
// The process that beats for every session here; `join` and `continue` start it.
|
|
70
|
+
export const pulse = async (o) => {
|
|
71
|
+
process.exitCode = await Heartbeat.pulse({ every: Number(opt(o, 'beat') ?? 30), once: flag(o, 'once'), log: s => process.stderr.write(`ringi: ${s}\n`) });
|
|
72
|
+
};
|
|
45
73
|
export const sessions = (o) => {
|
|
46
74
|
if (o._[0] === 'forget') {
|
|
47
75
|
const handle = o._[1] ?? fail('usage: ringi sessions forget <handle>');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Session interface: the operator's words become typed app requests.
|
|
2
|
+
import * as Heartbeat from '../app/heartbeat.js';
|
|
2
3
|
import * as Session from '../app/session.js';
|
|
3
4
|
import { fail } from '../lib/index.js';
|
|
4
5
|
import { opt, out } from './args.js';
|
|
@@ -30,9 +31,14 @@ export const join = (o) => {
|
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
32
33
|
out(`joined ${r.hub}/${r.room} as ${r.agent}`);
|
|
34
|
+
Heartbeat.ensurePulse(process.argv[1]);
|
|
33
35
|
briefing(r.session);
|
|
34
36
|
};
|
|
35
|
-
export const resume = (o) =>
|
|
37
|
+
export const resume = (o) => {
|
|
38
|
+
const s = Session.resume(o._[0]);
|
|
39
|
+
Heartbeat.ensurePulse(process.argv[1]);
|
|
40
|
+
briefing(s);
|
|
41
|
+
};
|
|
36
42
|
export const stop = () => {
|
|
37
43
|
const s = Session.stop();
|
|
38
44
|
out(`stopped: ${s.agent} keeps its seat in ${s.hub}/${s.room} and reacts to nothing until the operator says "continue"`);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// heartbeat: whether an agent is alive, and working or waiting, judged from what its machine shows without asking the
|
|
2
|
+
// model, so no tokens are spent on it. The facts: its process and process tree (platform.ts), the trace its tool
|
|
3
|
+
// writes (tools.ts) and ringi's own session record. What to do about a silent or stuck agent is the orchestrator's.
|
|
4
|
+
import { SUPPORTED, alive, table, treeCpu } from './platform.js';
|
|
5
|
+
import { toolOf, traceTime } from './tools.js';
|
|
6
|
+
// The facts of one session now; `rows` lets one `ps` call serve many sessions.
|
|
7
|
+
export const probe = (s, waits = null, rows = SUPPORTED ? table() : null) => ({
|
|
8
|
+
at: Date.now() / 1000,
|
|
9
|
+
alive: s.pid && SUPPORTED ? alive(s.pid) : null,
|
|
10
|
+
cpu: s.pid && s.product === 'watch' ? treeCpu(s.pid, rows) : null,
|
|
11
|
+
trace: traceTime(s),
|
|
12
|
+
seen: Date.parse(s.lastSeen) / 1000,
|
|
13
|
+
stopped: s.state === 'stopped',
|
|
14
|
+
waits,
|
|
15
|
+
});
|
|
16
|
+
export const tracedBy = (s) => toolOf(s)?.tool ?? (s.product === 'watch' ? 'watch' : 'none');
|
|
17
|
+
// working: its process tree used CPU since the previous look, or its trace or a ringi command is younger than
|
|
18
|
+
// `active` seconds; waiting: alive but neither, or kept waiting by the operator or its room; gone: its process is
|
|
19
|
+
// known to have ended.
|
|
20
|
+
export const judge = (cur, prev = null, { active = 120, next = 30 } = {}) => {
|
|
21
|
+
const quiet = Math.max(0, Math.round(cur.at - Math.max(cur.seen, cur.trace ?? 0)));
|
|
22
|
+
const base = { alive: cur.alive, quiet, cpu: cur.cpu, next };
|
|
23
|
+
if (cur.alive === false)
|
|
24
|
+
return { ...base, state: 'gone', waits: null };
|
|
25
|
+
if (cur.stopped)
|
|
26
|
+
return { ...base, state: 'waiting', waits: 'stopped' };
|
|
27
|
+
if (cur.waits)
|
|
28
|
+
return { ...base, state: 'waiting', waits: cur.waits };
|
|
29
|
+
const spent = prev?.cpu != null && cur.cpu != null && cur.cpu - prev.cpu >= 0.05;
|
|
30
|
+
return spent || quiet < active ? { ...base, state: 'working', waits: null } : { ...base, state: 'waiting', waits: 'inbox' };
|
|
31
|
+
};
|
|
32
|
+
// The text of a heartbeat: facts as trailers, no word of the work itself.
|
|
33
|
+
export const body = (b) => [
|
|
34
|
+
`State: ${b.state}`,
|
|
35
|
+
...(b.waits ? [`Waits: ${b.waits}`] : []),
|
|
36
|
+
`Alive: ${b.alive === null ? 'unknown' : b.alive ? 'yes' : 'no'}`,
|
|
37
|
+
`Quiet: ${b.quiet}`,
|
|
38
|
+
...(b.cpu === null ? [] : [`CPU: ${b.cpu.toFixed(1)}`]),
|
|
39
|
+
`Next: ${b.next}`,
|
|
40
|
+
].join('\n') + '\n';
|
|
41
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// platform: what the operating system says about a process, on macOS and Linux (Ubuntu). Elsewhere every probe says
|
|
2
|
+
// nothing (null) rather than guess.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
export const SUPPORTED = process.platform === 'darwin' || process.platform === 'linux';
|
|
6
|
+
// Whether the process exists; a process of another user that we may not signal still exists.
|
|
7
|
+
export const alive = (pid) => {
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
return e.code === 'EPERM';
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
// `ps` cumulative CPU time: `[DD-][HH:]MM:SS[.ss]` on both systems.
|
|
17
|
+
export const seconds = (time) => {
|
|
18
|
+
const [days, rest] = time.includes('-') ? [Number(time.split('-')[0]), time.split('-')[1]] : [0, time];
|
|
19
|
+
return days * 86400 + rest.split(':').reduce((acc, part) => acc * 60 + Number(part), 0);
|
|
20
|
+
};
|
|
21
|
+
// One line of /proc/<pid>/stat: the parent and the CPU seconds (user + system, in clock ticks of 1/100 s). The command
|
|
22
|
+
// name is in parentheses and may hold spaces, so the fields are counted after the last `)`.
|
|
23
|
+
export const procStat = (line) => {
|
|
24
|
+
const f = line.slice(line.lastIndexOf(')') + 2).split(' ');
|
|
25
|
+
const ppid = Number(f[1]), cpu = (Number(f[11]) + Number(f[12])) / 100;
|
|
26
|
+
return Number.isFinite(ppid) && Number.isFinite(cpu) ? { ppid, cpu } : null;
|
|
27
|
+
};
|
|
28
|
+
// Linux: /proc gives CPU to the hundredth of a second, where `ps` rounds to whole seconds.
|
|
29
|
+
const proc = () => {
|
|
30
|
+
let pids;
|
|
31
|
+
try {
|
|
32
|
+
pids = fs.readdirSync('/proc').filter(d => /^\d+$/.test(d));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const rows = new Map();
|
|
38
|
+
for (const pid of pids) {
|
|
39
|
+
try {
|
|
40
|
+
const row = procStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'));
|
|
41
|
+
if (row)
|
|
42
|
+
rows.set(Number(pid), row);
|
|
43
|
+
}
|
|
44
|
+
catch { /* the process ended meanwhile */ }
|
|
45
|
+
}
|
|
46
|
+
return rows.size ? rows : null;
|
|
47
|
+
};
|
|
48
|
+
// Every process with its parent and CPU seconds: /proc on Linux, else one `ps` call.
|
|
49
|
+
export const table = () => {
|
|
50
|
+
if (!SUPPORTED)
|
|
51
|
+
return null;
|
|
52
|
+
if (process.platform === 'linux') {
|
|
53
|
+
const rows = proc();
|
|
54
|
+
if (rows)
|
|
55
|
+
return rows;
|
|
56
|
+
}
|
|
57
|
+
const r = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,time='], { encoding: 'utf8', timeout: 10000 });
|
|
58
|
+
if (r.status !== 0)
|
|
59
|
+
return null;
|
|
60
|
+
const rows = new Map();
|
|
61
|
+
for (const line of r.stdout.split('\n')) {
|
|
62
|
+
const [pid, ppid, time] = line.trim().split(/\s+/);
|
|
63
|
+
if (pid && time)
|
|
64
|
+
rows.set(Number(pid), { ppid: Number(ppid), cpu: seconds(time) });
|
|
65
|
+
}
|
|
66
|
+
return rows;
|
|
67
|
+
};
|
|
68
|
+
// CPU seconds the process and all its descendants used so far: a turn's model client, its tools and their children.
|
|
69
|
+
export const treeCpu = (pid, rows = table()) => {
|
|
70
|
+
if (!rows?.has(pid))
|
|
71
|
+
return null;
|
|
72
|
+
const children = new Map();
|
|
73
|
+
for (const [p, { ppid }] of rows)
|
|
74
|
+
children.set(ppid, [...children.get(ppid) ?? [], p]);
|
|
75
|
+
let sum = 0;
|
|
76
|
+
for (const stack = [pid]; stack.length;) {
|
|
77
|
+
const p = stack.pop();
|
|
78
|
+
sum += rows.get(p)?.cpu ?? 0;
|
|
79
|
+
stack.push(...children.get(p) ?? []);
|
|
80
|
+
}
|
|
81
|
+
return sum;
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=platform.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// tools: the trace each agent tool writes of a session while it works, found by the session's id. Its modification
|
|
2
|
+
// time is the session's own activity, unlike the CPU of an app process that hosts many sessions.
|
|
3
|
+
// Claude Code $CLAUDE_CONFIG_DIR (or ~/.claude)/projects/<dir>/<session id>.jsonl, the session's transcript
|
|
4
|
+
// Codex $CODEX_HOME (or ~/.codex)/sessions/<yyyy>/<mm>/<dd>/rollout-<time>-<thread id>.jsonl
|
|
5
|
+
// A ringi watcher writes no trace of its own: the CPU of its process tree speaks for its turn (platform.ts).
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
const UUID = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
|
10
|
+
const dirs = (d) => { try {
|
|
11
|
+
return fs.readdirSync(d).sort().reverse();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [];
|
|
15
|
+
} };
|
|
16
|
+
const mtime = (f) => { try {
|
|
17
|
+
return fs.statSync(f).mtimeMs / 1000;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
} };
|
|
22
|
+
const claude = (id) => {
|
|
23
|
+
const root = path.join(process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude'), 'projects');
|
|
24
|
+
for (const d of dirs(root))
|
|
25
|
+
if (fs.existsSync(path.join(root, d, `${id}.jsonl`)))
|
|
26
|
+
return path.join(root, d, `${id}.jsonl`);
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
const codex = (id) => {
|
|
30
|
+
const root = path.join(process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex'), 'sessions');
|
|
31
|
+
for (const y of dirs(root))
|
|
32
|
+
for (const m of dirs(path.join(root, y)))
|
|
33
|
+
for (const d of dirs(path.join(root, y, m))) {
|
|
34
|
+
const f = dirs(path.join(root, y, m, d)).find(n => n.startsWith('rollout-') && n.endsWith(`-${id}.jsonl`));
|
|
35
|
+
if (f)
|
|
36
|
+
return path.join(root, y, m, d, f);
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
// A trace once found keeps its path; only its time is read again.
|
|
41
|
+
const found = new Map();
|
|
42
|
+
// Which tool a session runs and the id its trace is named by: Claude Code sessions are `claude:<id>`; a Codex chat
|
|
43
|
+
// names itself with RINGI_SESSION=codex-<thread id>.
|
|
44
|
+
export const toolOf = (s) => {
|
|
45
|
+
const id = UUID.exec(s.handle)?.[1];
|
|
46
|
+
if (!id)
|
|
47
|
+
return null;
|
|
48
|
+
if (s.handle.startsWith('claude:') || s.product === 'claude')
|
|
49
|
+
return { tool: 'claude', id };
|
|
50
|
+
if (s.product === 'codex' || s.handle.startsWith('env:codex-'))
|
|
51
|
+
return { tool: 'codex', id };
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
// When the session's tool last wrote its trace (unix seconds), or null: no tool known, or no trace found.
|
|
55
|
+
export const traceTime = (s) => {
|
|
56
|
+
const t = toolOf(s);
|
|
57
|
+
if (!t)
|
|
58
|
+
return null;
|
|
59
|
+
const key = `${t.tool}:${t.id}`;
|
|
60
|
+
if (!found.has(key)) {
|
|
61
|
+
const f = t.tool === 'claude' ? claude(t.id) : codex(t.id);
|
|
62
|
+
if (!f)
|
|
63
|
+
return null;
|
|
64
|
+
found.set(key, f);
|
|
65
|
+
}
|
|
66
|
+
return mtime(found.get(key));
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=tools.js.map
|
|
@@ -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 }); }
|
|
@@ -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,7 +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
|
}
|
|
517
|
-
blob(file) {
|
|
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
|
+
}
|
|
518
562
|
// One message on top of the room's branch.
|
|
519
563
|
append(room, who, text, entries) {
|
|
520
564
|
if (room.archived)
|
|
@@ -565,6 +609,35 @@ export class GitStore {
|
|
|
565
609
|
.filter(([, , p]) => wanted.has(p)).map(([, n, p]) => [p, Number(n)]));
|
|
566
610
|
}
|
|
567
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
|
+
}
|
|
568
641
|
// An annotated tag without text in its author's namespace, on the message's commit, signed on a signed hub. The
|
|
569
642
|
// message's id in the name keeps it unique, so writing the same signal twice is a no-op.
|
|
570
643
|
signal(room, who, target, name) {
|
|
@@ -584,7 +657,8 @@ export class GitStore {
|
|
|
584
657
|
return this.git(['for-each-ref', '--format=%(refname) %(objectname) %(objecttype)', 'refs/tags/']).split('\n').flatMap(line => {
|
|
585
658
|
const [ref, object, type] = line.split(' ');
|
|
586
659
|
const m = /^refs\/tags\/([a-z0-9]+(?:[_-][a-z0-9]+)*)\.([a-z][a-z0-9.-]*\.[0-9a-f]{12})$/.exec(ref ?? '');
|
|
587
|
-
|
|
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 }] : [];
|
|
588
662
|
});
|
|
589
663
|
}
|
|
590
664
|
// Annotated tag objects read whole, size-framed: their target, tagger and text are never split on bytes they hold.
|
|
@@ -605,7 +679,8 @@ export class GitStore {
|
|
|
605
679
|
const hdr = body.subarray(0, split < 0 ? body.length : split).toString('utf8');
|
|
606
680
|
const text = split < 0 ? '' : body.subarray(split + 2).toString('utf8').split('-----BEGIN SSH SIGNATURE-----')[0];
|
|
607
681
|
const target = /^object ([0-9a-f]+)\ntype commit$/m.exec(hdr)?.[1] ?? '';
|
|
608
|
-
|
|
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 });
|
|
609
684
|
}
|
|
610
685
|
return out;
|
|
611
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.
|