@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
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -22,6 +22,7 @@ With no words: `ringi agent whoami` if this chat is joined; otherwise say how th
|
|
|
22
22
|
Several chats may be open in a project. Take part only if the operator says so in this chat. Their words map to session commands:
|
|
23
23
|
|
|
24
24
|
- *"join fix-badge as codex; review tasks from claude; no commits"* → `ringi join local fix-badge --as codex --scope "review tasks from claude; no commits"`. Record what they delegate in `--scope`.
|
|
25
|
+
- A room may have a quorum (`ringi room create … --quorum 2`; `--quorum 1`: start at once). While join or inbox says `waiting:`, do nothing and end the turn. When it says `started:`, take your first turn right away on what it shows (open work, inbox), without asking the operator to confirm anything: with no scope, the room's work for you is yours, and you agree the split with the other members yourselves. Still ask before commits or pushes, publishing, outside accounts or spending that the scope does not cover. The same after *"continue"*.
|
|
25
26
|
- *"continue"* (after a restart, or a pause) → `ringi continue`. If it lists candidates, ask the operator which one, then `ringi continue <room>/<agent>`.
|
|
26
27
|
- *"stop"* (the operator leaves for a while) → `ringi stop`. You keep your seat and react to nothing.
|
|
27
28
|
- *"leave"* → `ringi leave`.
|
|
@@ -34,15 +35,17 @@ Everything you do as an agent goes through `ringi agent`, always in your own roo
|
|
|
34
35
|
|
|
35
36
|
```sh
|
|
36
37
|
ringi agent inbox # what is new for you; marks it seen
|
|
37
|
-
ringi agent
|
|
38
|
+
ringi agent add report.md patch.diff:files/fix.diff # stage files for your next message, like git add
|
|
39
|
+
ringi agent reply <id> -m "Done: tests pass" --state completed # carries what you staged (or --attach …)
|
|
38
40
|
ringi agent send --to claude --task -m "Review the parser" -m "acceptance: …"
|
|
39
41
|
ringi agent tasks | proposals | grants | signals | profile | thread <id> | read <id> | cat <id> <path>
|
|
40
42
|
ringi agent whoami # room, scope, members
|
|
41
43
|
```
|
|
42
44
|
|
|
43
|
-
- The first `-m` is the subject; the rest is the body; anything long goes into files
|
|
45
|
+
- The first `-m` is the subject; the rest is the body; anything long goes into files.
|
|
46
|
+
- Results and state travel only as attachments: `ringi agent add <file>` takes the file's content now for your next message (`add` alone lists what is staged, `--drop <path>` takes one back), or `--attach` on the message. Read a peer's with `ringi agent cat` / `get`. Never leave drafts or results in the project directory or any shared disk for others to pick up, and never pick them up there: peers may be on other machines, and only the room carries them. Keep your own drafts outside the project.
|
|
44
47
|
- Answer the message you act on (`reply <id>`): only these links order the room.
|
|
45
|
-
- What a piece of work is in your room, its states and who may move it, is the room's **profile**: run `ringi agent profile` once per room before you set states. Do not assume the A2A states; a room may use any. `--task` opens a piece of work of that profile.
|
|
48
|
+
- What a piece of work is in your room, its states and who may move it, is the room's **profile**: run `ringi agent profile` once per room before you set states. Do not assume the A2A states; a room may use any. `--task` opens a piece of work of that profile.
|
|
46
49
|
- When you start on a piece of work, `ringi agent take <id>`: a mark others see, not a state change. Reading your inbox leaves read marks on what was delivered; `--peek` leaves none.
|
|
47
50
|
- `--roles id=label` labels mean only what the profile's rules say. A label you give in a message authorizes nothing in that same message.
|
|
48
51
|
|
|
@@ -7,6 +7,11 @@ export type InboxOptions = {
|
|
|
7
7
|
peek?: boolean;
|
|
8
8
|
limit?: number;
|
|
9
9
|
};
|
|
10
|
+
export declare const started: ({ R }: Actor) => {
|
|
11
|
+
quorum: number | null;
|
|
12
|
+
agents: number;
|
|
13
|
+
started: boolean;
|
|
14
|
+
};
|
|
10
15
|
export declare const inbox: ({ R, me }: Actor, { peek, limit }?: InboxOptions) => {
|
|
11
16
|
rest: number;
|
|
12
17
|
cursors: {
|
|
@@ -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
|
@@ -3,6 +3,7 @@ import * as Signals from '../domain/signals.ts';
|
|
|
3
3
|
import type { Room } from '../hub/index.ts';
|
|
4
4
|
import type { Entry } from '../protocol/files.ts';
|
|
5
5
|
import type { All, Message } from '../protocol/index.ts';
|
|
6
|
+
import type { Staged } from '../runtime/reader.ts';
|
|
6
7
|
import type { TaskView } from './view.ts';
|
|
7
8
|
export type Actor = {
|
|
8
9
|
R: Room;
|
|
@@ -27,6 +28,10 @@ export type Sent = {
|
|
|
27
28
|
to: string[];
|
|
28
29
|
files: number;
|
|
29
30
|
};
|
|
31
|
+
export declare const FILE_MAX: number;
|
|
32
|
+
export declare const staged: ({ R, me }: Actor) => Staged[];
|
|
33
|
+
export declare const stage: ({ R, me }: Actor, files: Entry[]) => Staged[];
|
|
34
|
+
export declare const unstage: (a: Actor, paths: string[] | null) => Staged[];
|
|
30
35
|
export declare const send: ({ R, me }: Actor, x: SendInput) => Sent;
|
|
31
36
|
export declare const marks: (R: Room, all: All) => Map<string, Record<string, string[]>>;
|
|
32
37
|
export declare const message: ({ R, me }: Actor, ref: string) => {
|
|
@@ -12,8 +12,9 @@ export type RoomView = {
|
|
|
12
12
|
export declare const rooms: (H: Hub, { archived }?: {
|
|
13
13
|
archived?: boolean;
|
|
14
14
|
}) => RoomView[];
|
|
15
|
-
export declare const create: (H: Hub, name: string, { profile }?: {
|
|
15
|
+
export declare const create: (H: Hub, name: string, { profile, quorum }?: {
|
|
16
16
|
profile?: string;
|
|
17
|
+
quorum?: number;
|
|
17
18
|
}) => {
|
|
18
19
|
profile: string;
|
|
19
20
|
};
|
|
@@ -5,6 +5,11 @@ export type { Session } from '../runtime/session.ts';
|
|
|
5
5
|
export declare const briefing: (s: Session) => {
|
|
6
6
|
session: Session;
|
|
7
7
|
actor: import("../runtime/session.ts").Bound;
|
|
8
|
+
start: {
|
|
9
|
+
quorum: number | null;
|
|
10
|
+
agents: number;
|
|
11
|
+
started: boolean;
|
|
12
|
+
};
|
|
8
13
|
tasks: import("./view.ts").TaskView[];
|
|
9
14
|
inbox: {
|
|
10
15
|
rest: number;
|
|
@@ -35,7 +40,7 @@ export declare const briefing: (s: Session) => {
|
|
|
35
40
|
hopEff: number;
|
|
36
41
|
files: import("../protocol/index.ts").FileRef[];
|
|
37
42
|
}[];
|
|
38
|
-
};
|
|
43
|
+
} | null;
|
|
39
44
|
};
|
|
40
45
|
export type JoinInput = {
|
|
41
46
|
hub: 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;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { TaskView } from '../app/view.ts';
|
|
2
2
|
import type { Opts } from './args.ts';
|
|
3
3
|
import type { Ctx } from './context.ts';
|
|
4
|
+
import type { Session } from '../app/session.ts';
|
|
5
|
+
export declare const add: (a: Ctx, o: Opts) => void;
|
|
4
6
|
export declare const send: (a: Ctx, o: Opts) => void;
|
|
5
7
|
export declare const reply: (a: Ctx, o: Opts) => void;
|
|
6
8
|
export declare const read: (a: Ctx, o: Opts) => void;
|
|
@@ -18,3 +20,8 @@ export declare const stamp_: (a: Ctx, o: Opts) => void;
|
|
|
18
20
|
export declare const decide: (a: Ctx, o: Opts) => void;
|
|
19
21
|
export declare const proposals: (a: Ctx, o: Opts) => void;
|
|
20
22
|
export declare const grants: (a: Ctx, o: Opts) => void;
|
|
23
|
+
export declare const scopeLine: (s: Session, quorum?: boolean) => string;
|
|
24
|
+
export declare const waiting: (room: string, st: {
|
|
25
|
+
quorum: number | null;
|
|
26
|
+
agents: number;
|
|
27
|
+
}) => string;
|
|
@@ -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;
|
|
@@ -39,6 +39,12 @@ export type Room = {
|
|
|
39
39
|
authorityAt: D.AuthorityAt;
|
|
40
40
|
profile: Profile;
|
|
41
41
|
members: Record<string, Member>;
|
|
42
|
+
quorum: number | null;
|
|
43
|
+
};
|
|
44
|
+
export declare const start: (R: Pick<Room, 'members' | 'quorum'>) => {
|
|
45
|
+
quorum: number | null;
|
|
46
|
+
agents: number;
|
|
47
|
+
started: boolean;
|
|
42
48
|
};
|
|
43
49
|
export declare const hubNames: () => string[];
|
|
44
50
|
export declare const createHub: (name: string, { signed, url }?: {
|
|
@@ -15,6 +15,13 @@ export type ReaderState = {
|
|
|
15
15
|
runs?: number[];
|
|
16
16
|
exhausted?: Record<string, boolean>;
|
|
17
17
|
signals?: Record<string, string>;
|
|
18
|
+
staged?: Staged[];
|
|
19
|
+
};
|
|
20
|
+
export type Staged = {
|
|
21
|
+
path: string;
|
|
22
|
+
blob: string;
|
|
23
|
+
mode: string;
|
|
24
|
+
size: number;
|
|
18
25
|
};
|
|
19
26
|
export declare const readerFile: (R: Room, reader: string) => string;
|
|
20
27
|
export declare const readerLock: <T>(R: Room, reader: string, fn: () => T) => T;
|
|
@@ -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,6 +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[];
|
|
62
|
+
blob(file: string): {
|
|
63
|
+
id: string;
|
|
64
|
+
size: number;
|
|
65
|
+
};
|
|
66
|
+
retain(room: StoredRoom, who: string, entries: Entry[]): void;
|
|
60
67
|
append(room: StoredRoom, who: string, text: string, entries: Entry[]): string;
|
|
61
68
|
private onTop;
|
|
62
69
|
verifyLog(room: StoredRoom, id: string, head: string): string | null;
|
|
@@ -64,6 +71,13 @@ export declare class GitStore implements Store {
|
|
|
64
71
|
unpin(room: StoredRoom, reader: string, id: string): void;
|
|
65
72
|
file(rev: string, p: string): Buffer;
|
|
66
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
|
+
}>;
|
|
67
81
|
signal(room: StoredRoom, who: string, target: string, name: string): void;
|
|
68
82
|
private tags;
|
|
69
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,6 +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): {
|
|
79
|
+
id: Rev;
|
|
80
|
+
size: number;
|
|
81
|
+
};
|
|
82
|
+
retain(room: StoredRoom, who: string, entries: Entry[]): void;
|
|
78
83
|
alarms?(room: StoredRoom): string[];
|
|
79
84
|
verifyLog(room: StoredRoom, id: string, head: Rev): string | null;
|
|
80
85
|
pin(room: StoredRoom, reader: string, id: string, rev: Rev): void;
|
|
@@ -83,6 +88,13 @@ export interface Store {
|
|
|
83
88
|
sizes(rev: Rev, paths: string[]): Map<string, number>;
|
|
84
89
|
signal(room: StoredRoom, who: string, target: Rev, name: string): void;
|
|
85
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
|
+
}>;
|
|
86
98
|
admit?(room: StoredRoom, who: string, key: string): Rev;
|
|
87
99
|
joinKey?(room: StoredRoom, who: string): JoinKey;
|
|
88
100
|
forgetLog?(room: StoredRoom, id: string): void;
|
package/target/esm/app/agent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Agent operations return data; the runtime owns readers, seats and headless turns.
|
|
2
|
-
import { HIDDEN, append, pongText, withSizes } from '../hub/index.js';
|
|
2
|
+
import { HIDDEN, append, pongText, start, withSizes } from '../hub/index.js';
|
|
3
3
|
import * as Signals from '../hub/signals.js';
|
|
4
4
|
import { fail, sleep } from '../lib/index.js';
|
|
5
5
|
import { ID_RE, checkOutgoing, compose, link, lookup, unread } from '../protocol/index.js';
|
|
@@ -8,6 +8,8 @@ import { bound } from '../runtime/session.js';
|
|
|
8
8
|
import { isRef } from './room.js';
|
|
9
9
|
export { bound } from '../runtime/session.js';
|
|
10
10
|
export { watch } from '../runtime/watch.js';
|
|
11
|
+
// Whether the agent's room has its quorum and started its work; a room that has not is not read.
|
|
12
|
+
export const started = ({ R }) => start(R);
|
|
11
13
|
export const inbox = ({ R, me }, { peek = false, limit = 20 } = {}) => {
|
|
12
14
|
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
13
15
|
fail('inbox limit must be a positive integer');
|
|
@@ -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// room: what any participant (the operator or an agent) does in a room: write messages and read them, follow work,
|
|
2
2
|
// check signals. Each operation takes who acts and typed input, and returns data; interfaces print it.
|
|
3
|
+
import fs from 'node:fs';
|
|
3
4
|
import * as D from '../domain/decisions.js';
|
|
4
5
|
import { isOpen, tasksIn } from '../domain/profiles.js';
|
|
5
6
|
import * as Signals from '../domain/signals.js';
|
|
@@ -12,6 +13,51 @@ import { ID_RE, TOKEN_RE, checkOutgoing, compose, link, lookup, ordered, rootOf,
|
|
|
12
13
|
import { linked, loadRoomUnlocked, loadState, readerLock, saveState } from '../runtime/reader.js';
|
|
13
14
|
import { taskView } from './view.js';
|
|
14
15
|
export const isRef = (R) => (x) => R.hub.store.isRef(x);
|
|
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. 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' }));
|
|
24
|
+
export const staged = ({ R, me }) => loadState(R, me).staged ?? [];
|
|
25
|
+
export const stage = ({ R, me }, files) => {
|
|
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
|
+
});
|
|
34
|
+
return readerLock(R, me, () => {
|
|
35
|
+
const st = loadState(R, me);
|
|
36
|
+
const next = new Map((st.staged ?? []).map(s => [s.path, s]));
|
|
37
|
+
for (const s of taken)
|
|
38
|
+
next.set(s.path, s);
|
|
39
|
+
const all = [...next.values()];
|
|
40
|
+
checkCollisions(all.map(s => s.path));
|
|
41
|
+
R.hub.store.retain(R.s, me, kept([...all, ...st.staged ?? []]));
|
|
42
|
+
saveState(R, me, { ...st, staged: all });
|
|
43
|
+
R.hub.store.retain(R.s, me, kept(all));
|
|
44
|
+
return all;
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const shrink = ({ R, me }, keep) => {
|
|
48
|
+
const st = loadState(R, me);
|
|
49
|
+
const left = (st.staged ?? []).filter(keep);
|
|
50
|
+
saveState(R, me, { ...st, staged: left });
|
|
51
|
+
R.hub.store.retain(R.s, me, kept(left));
|
|
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));
|
|
60
|
+
});
|
|
15
61
|
// A message from the actor. The operator may omit `to`: the room, or the assignees when there are any.
|
|
16
62
|
export const send = ({ R, me }, x) => {
|
|
17
63
|
const P = R.profile;
|
|
@@ -50,7 +96,13 @@ export const send = ({ R, me }, x) => {
|
|
|
50
96
|
fail(`grant ${short(g.id)} was revoked`);
|
|
51
97
|
grant = g.id;
|
|
52
98
|
}
|
|
53
|
-
|
|
99
|
+
// What the actor staged goes along, an explicit attachment of the same path in its place.
|
|
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);
|
|
104
|
+
const named = new Set(explicit.map(e => e.path));
|
|
105
|
+
const entries = [...stagedNow.filter(s => !named.has(s.path)).map(s => ({ path: s.path, blob: s.blob, mode: s.mode })), ...explicit];
|
|
54
106
|
checkCollisions(entries.map(e => e.path));
|
|
55
107
|
const text = compose(x.text, [
|
|
56
108
|
['To', to.join(', ')], ['Re', re?.id], ['Kind', x.task && P.opens], ['State', x.state], ['Assign', assign?.join(', ')],
|
|
@@ -58,6 +110,8 @@ export const send = ({ R, me }, x) => {
|
|
|
58
110
|
]);
|
|
59
111
|
checkOutgoing(text, isRef(R));
|
|
60
112
|
const id = append(R, me, text, entries);
|
|
113
|
+
if (stagedNow.length)
|
|
114
|
+
readerLock(R, me, () => shrink({ R, me }, s => !stagedNow.some(t => t.path === s.path && t.blob === s.blob)));
|
|
61
115
|
if (to.length === 1 && to[0] === '*')
|
|
62
116
|
note(`addressed to the whole room: this ${x.task ? P.opens : 'message'} wakes nobody; name recipients (--to or --assign) to start work`);
|
|
63
117
|
return { id, to, files: entries.length };
|
package/target/esm/app/rooms.js
CHANGED
|
@@ -36,9 +36,12 @@ export const rooms = (H, { archived = false } = {}) => {
|
|
|
36
36
|
return [...live, ...gone];
|
|
37
37
|
};
|
|
38
38
|
// A room carries its profile from the start: later changes to the hub's profiles do not reach it.
|
|
39
|
-
|
|
39
|
+
// `quorum`: the room's work starts by itself once it has that many agents; until then a join waits.
|
|
40
|
+
export const create = (H, name, { profile, quorum } = {}) => {
|
|
40
41
|
if (!ID_RE.test(name) || RESERVED_ROOMS.has(name))
|
|
41
|
-
fail(`usage: ringi room create <id> [--profile <name>] (an id like fix-badge; not ${[...RESERVED_ROOMS].join(', ')})`);
|
|
42
|
+
fail(`usage: ringi room create <id> [--profile <name>] [--quorum <agents>] (an id like fix-badge; not ${[...RESERVED_ROOMS].join(', ')})`);
|
|
43
|
+
if (quorum !== undefined && !(Number.isSafeInteger(quorum) && quorum >= 1 && quorum <= 99))
|
|
44
|
+
fail('--quorum takes a number of agents, 1 to 99');
|
|
42
45
|
const p = profile ?? H.contract.profile ?? DEFAULT_PROFILE;
|
|
43
46
|
const text = profileText(H, p) ?? fail(`hub ${H.name} carries no profile ${p}; add it with ringi hub amend -m "<why>" --attach <file>:profiles/${p}.json`);
|
|
44
47
|
parseProfile(text, `profile ${p}`);
|
|
@@ -48,7 +51,8 @@ export const create = (H, name, { profile } = {}) => {
|
|
|
48
51
|
if (H.signed)
|
|
49
52
|
files.allowed_signers = H.store.rootRoster?.() ?? fail(`hub ${H.name}: its store cannot start a signed roster`);
|
|
50
53
|
// The genesis names the room; a random id makes it unique, so a room deleted and created again is a different room.
|
|
51
|
-
H.store.createRoom(name, OPERATOR, compose(`room ${name}`, [['To', '*'], ['Kind', 'room'], ['Room-Id', crypto.randomBytes(16).toString('hex')]
|
|
54
|
+
H.store.createRoom(name, OPERATOR, compose(`room ${name}`, [['To', '*'], ['Kind', 'room'], ['Room-Id', crypto.randomBytes(16).toString('hex')],
|
|
55
|
+
...(quorum ? [['Quorum', String(quorum)]] : [])]), files);
|
|
52
56
|
});
|
|
53
57
|
setConfig({ hub: H.name, room: name });
|
|
54
58
|
return { profile: p };
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// Session use cases preserve the operator's identity, seat and delegated scope.
|
|
2
2
|
import { OPERATOR, ensureHome, locked } from '../home/index.js';
|
|
3
|
-
import { controlChange, openHub, room as roomOf } from '../hub/index.js';
|
|
3
|
+
import { controlChange, openHub, room as roomOf, start } from '../hub/index.js';
|
|
4
4
|
import { AGENT_RE } from '../protocol/index.js';
|
|
5
5
|
import { allSessions, bound, dropSession, liveness, ownSession, parseSelector, pidAlive, readSession, saveSession, seatHolder, whoAmI } from '../runtime/session.js';
|
|
6
6
|
import { fail, note, stamp } from '../lib/index.js';
|
|
7
7
|
import { inbox } from './agent.js';
|
|
8
8
|
import { tasks } from './room.js';
|
|
9
9
|
export { bound } from '../runtime/session.js';
|
|
10
|
+
// A room still waiting for its agents is not read: nothing is marked seen before its work starts.
|
|
10
11
|
export const briefing = (s) => {
|
|
11
12
|
const actor = bound();
|
|
12
|
-
|
|
13
|
+
const st = start(actor.R);
|
|
14
|
+
return { session: s, actor, start: st, tasks: st.started ? tasks(actor).views : [], inbox: st.started ? inbox(actor) : null };
|
|
13
15
|
};
|
|
14
16
|
export const join = ({ hub, room, agent: id, scope }) => {
|
|
15
17
|
const me = whoAmI();
|