@saadnvd1/agent-os 0.3.697 → 0.3.700
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/app/api/bus/send/route.ts +11 -1
- package/app/api/bus/wait/route.ts +22 -0
- package/app/api/peer/hello/reveal/route.ts +6 -0
- package/app/api/peer/hello/route.ts +9 -0
- package/app/api/peer/hello/status/route.ts +6 -0
- package/app/api/peer/ping/route.ts +10 -0
- package/app/api/peers/account/route.ts +9 -0
- package/app/api/peers/gate.test.ts +111 -0
- package/app/api/peers/link/[id]/confirm/route.ts +27 -0
- package/app/api/peers/link/[id]/route.ts +20 -0
- package/app/api/peers/link/route.ts +23 -0
- package/app/api/peers/pending/[id]/accept/route.ts +31 -0
- package/app/api/peers/pending/[id]/decline/route.ts +13 -0
- package/app/api/peers/pending/route.ts +7 -0
- package/app/api/sessions/route.ts +18 -1
- package/app/page.tsx +2 -0
- package/components/Hosts/AccountMachines.tsx +180 -0
- package/components/Hosts/HostRow.tsx +44 -35
- package/components/Hosts/HostsDialog.tsx +49 -37
- package/components/Hosts/IncomingLinkSheet.tsx +228 -0
- package/components/Hosts/LinkCodeSheet.tsx +186 -0
- package/components/Hosts/sheet.ts +3 -0
- package/components/SessionList/RowSubtitle.tsx +11 -1
- package/components/Welcome/ConnectBlock.tsx +1 -1
- package/data/peers/index.ts +2 -0
- package/data/peers/keys.ts +6 -0
- package/data/peers/queries.ts +141 -0
- package/lib/__fixtures__/connect-transport.ts +93 -0
- package/lib/__fixtures__/peer-machine.ts +45 -0
- package/lib/__fixtures__/peer-rpc.ts +224 -0
- package/lib/agents/brief.ts +3 -1
- package/lib/bus/format.ts +25 -4
- package/lib/bus/index.ts +113 -106
- package/lib/bus/resolve.ts +16 -2
- package/lib/bus/targets.ts +122 -0
- package/lib/bus/waits/kick.ts +12 -0
- package/lib/bus/waits/lines.ts +69 -0
- package/lib/bus/waits/register.ts +83 -0
- package/lib/bus/waits/store.ts +196 -0
- package/lib/bus/waits/tick.ts +192 -0
- package/lib/connect/control-api.ts +108 -2
- package/lib/connect/heartbeat.ts +11 -3
- package/lib/connect/serve.ts +2 -0
- package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
- package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
- package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
- package/lib/db/types.ts +6 -0
- package/lib/done/archive.ts +2 -0
- package/lib/hosts/index.ts +28 -7
- package/lib/hosts/peer-socket.ts +3 -1
- package/lib/hosts/read-capped-bytes.ts +23 -0
- package/lib/hosts/remote-api.ts +112 -41
- package/lib/orchestrator/describe.ts +13 -0
- package/lib/orchestrator/facts.ts +17 -0
- package/lib/peer/account.ts +198 -0
- package/lib/peer/allowlist.ts +136 -0
- package/lib/peer/audit.ts +144 -0
- package/lib/peer/cli.ts +24 -0
- package/lib/peer/client.ts +188 -0
- package/lib/peer/code.ts +3 -0
- package/lib/peer/envelope.ts +424 -0
- package/lib/peer/hello-route.ts +32 -0
- package/lib/peer/hello.ts +346 -0
- package/lib/peer/internal.ts +22 -0
- package/lib/peer/keys.ts +122 -0
- package/lib/peer/link-cli.ts +233 -0
- package/lib/peer/links.ts +96 -0
- package/lib/peer/machines.ts +72 -0
- package/lib/peer/nonce-store.ts +23 -0
- package/lib/peer/notify.ts +32 -0
- package/lib/peer/pairing-out.ts +382 -0
- package/lib/peer/pairing.ts +403 -0
- package/lib/peer/pin.ts +55 -0
- package/lib/peer/replay.ts +81 -0
- package/lib/peer/rpc-intake.ts +258 -0
- package/lib/peer/rpc-server.ts +369 -0
- package/lib/schedules/when.ts +1 -1
- package/lib/security/auth.ts +51 -6
- package/lib/security/gate.ts +38 -12
- package/lib/security/route-guard.ts +3 -1
- package/lib/session-artifacts/peer.ts +12 -1
- package/lib/sessions/end.ts +3 -0
- package/lib/tasks/contract/authorize.ts +4 -0
- package/lib/worktree-sweep/testTemp.ts +2 -0
- package/package.json +1 -1
- package/scripts/agent-os +2 -0
- package/scripts/lib/commands.sh +2 -0
- package/server.ts +21 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waits: session A waiting on session B, kept in `bus_waits`. A change of
|
|
3
|
+
* state is claimed with a conditional UPDATE and acted on only by the caller
|
|
4
|
+
* whose update changed a row, so overlapping sweeps and restarts never
|
|
5
|
+
* wake anyone twice.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db, type Session } from "../../db";
|
|
9
|
+
import { isHeldRemote } from "../../tasks/contract/held-calls";
|
|
10
|
+
import { notifySessionsChanged } from "../../status/hub";
|
|
11
|
+
|
|
12
|
+
export type WaitKind = "reply" | "idle" | "merged";
|
|
13
|
+
export type WaitOutcome =
|
|
14
|
+
"replied" | "idle" | "merged" | "timeout" | "ended" | "cancelled";
|
|
15
|
+
|
|
16
|
+
export interface WaitRow {
|
|
17
|
+
id: number;
|
|
18
|
+
waiter_id: string;
|
|
19
|
+
target_id: string;
|
|
20
|
+
kind: WaitKind;
|
|
21
|
+
message_id: number | null;
|
|
22
|
+
request: string | null;
|
|
23
|
+
deadline: number;
|
|
24
|
+
created_at: number;
|
|
25
|
+
seen_busy_at: number | null;
|
|
26
|
+
nudged_at: number | null;
|
|
27
|
+
closed_at: number | null;
|
|
28
|
+
outcome: WaitOutcome | null;
|
|
29
|
+
reply_id: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const MAX_OPEN_WAITS = 10;
|
|
33
|
+
export const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
34
|
+
export const MIN_TIMEOUT_MS = 60_000;
|
|
35
|
+
export const MAX_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
36
|
+
|
|
37
|
+
// A request shown in a line: one line, cut.
|
|
38
|
+
export function excerpt(body: string, max = 100): string {
|
|
39
|
+
const one = body.replace(/\s+/g, " ").trim().replace(/"/g, "'");
|
|
40
|
+
return one.length <= max ? one : `${one.slice(0, max - 1)}…`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function openWait(w: {
|
|
44
|
+
waiterId: string;
|
|
45
|
+
targetId: string;
|
|
46
|
+
kind: WaitKind;
|
|
47
|
+
messageId?: number | null;
|
|
48
|
+
request?: string | null;
|
|
49
|
+
deadline: number;
|
|
50
|
+
now?: number;
|
|
51
|
+
}): number {
|
|
52
|
+
const now = w.now ?? Date.now();
|
|
53
|
+
const open = db
|
|
54
|
+
.prepare(
|
|
55
|
+
`SELECT id FROM bus_waits WHERE waiter_id = ? AND target_id = ? AND kind = ? AND closed_at IS NULL`
|
|
56
|
+
)
|
|
57
|
+
.get(w.waiterId, w.targetId, w.kind) as { id: number } | undefined;
|
|
58
|
+
let id: number;
|
|
59
|
+
if (open) {
|
|
60
|
+
// The same wait again: the newer request and deadline stand.
|
|
61
|
+
db.prepare(
|
|
62
|
+
`UPDATE bus_waits SET message_id = ?, request = ?, deadline = ?, nudged_at = NULL WHERE id = ?`
|
|
63
|
+
).run(w.messageId ?? null, w.request ?? null, w.deadline, open.id);
|
|
64
|
+
id = open.id;
|
|
65
|
+
} else {
|
|
66
|
+
const count = (
|
|
67
|
+
db
|
|
68
|
+
.prepare(
|
|
69
|
+
`SELECT COUNT(*) AS n FROM bus_waits WHERE waiter_id = ? AND closed_at IS NULL`
|
|
70
|
+
)
|
|
71
|
+
.get(w.waiterId) as { n: number }
|
|
72
|
+
).n;
|
|
73
|
+
if (count >= MAX_OPEN_WAITS)
|
|
74
|
+
throw new Error(
|
|
75
|
+
`You already have ${MAX_OPEN_WAITS} waits open; wait for one to finish`
|
|
76
|
+
);
|
|
77
|
+
id = Number(
|
|
78
|
+
db
|
|
79
|
+
.prepare(
|
|
80
|
+
`INSERT INTO bus_waits (waiter_id, target_id, kind, message_id, request, deadline, created_at)
|
|
81
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
82
|
+
)
|
|
83
|
+
.run(
|
|
84
|
+
w.waiterId,
|
|
85
|
+
w.targetId,
|
|
86
|
+
w.kind,
|
|
87
|
+
w.messageId ?? null,
|
|
88
|
+
w.request ?? null,
|
|
89
|
+
w.deadline,
|
|
90
|
+
now
|
|
91
|
+
).lastInsertRowid
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
notifySessionsChanged();
|
|
95
|
+
return id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// True for the one caller that closed it.
|
|
99
|
+
export function claimClose(
|
|
100
|
+
id: number,
|
|
101
|
+
outcome: WaitOutcome,
|
|
102
|
+
replyId: number | null = null,
|
|
103
|
+
now = Date.now()
|
|
104
|
+
): boolean {
|
|
105
|
+
const changed =
|
|
106
|
+
db
|
|
107
|
+
.prepare(
|
|
108
|
+
`UPDATE bus_waits SET closed_at = ?, outcome = ?, reply_id = ? WHERE id = ? AND closed_at IS NULL`
|
|
109
|
+
)
|
|
110
|
+
.run(now, outcome, replyId, id).changes === 1;
|
|
111
|
+
if (changed) notifySessionsChanged();
|
|
112
|
+
return changed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const claimNudge = (id: number, now = Date.now()): boolean =>
|
|
116
|
+
db
|
|
117
|
+
.prepare(
|
|
118
|
+
`UPDATE bus_waits SET nudged_at = ? WHERE id = ? AND closed_at IS NULL AND nudged_at IS NULL`
|
|
119
|
+
)
|
|
120
|
+
.run(now, id).changes === 1;
|
|
121
|
+
|
|
122
|
+
export function markSeenBusy(id: number, now = Date.now()): void {
|
|
123
|
+
db.prepare(
|
|
124
|
+
`UPDATE bus_waits SET seen_busy_at = ? WHERE id = ? AND seen_busy_at IS NULL`
|
|
125
|
+
).run(now, id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const openWaits = (): WaitRow[] =>
|
|
129
|
+
db
|
|
130
|
+
.prepare(`SELECT * FROM bus_waits WHERE closed_at IS NULL ORDER BY id`)
|
|
131
|
+
.all() as WaitRow[];
|
|
132
|
+
|
|
133
|
+
// A reply from `targetId` to `waiterId` settles these.
|
|
134
|
+
export const replyWaitsFor = (waiterId: string, targetId: string): WaitRow[] =>
|
|
135
|
+
db
|
|
136
|
+
.prepare(
|
|
137
|
+
`SELECT * FROM bus_waits WHERE waiter_id = ? AND target_id = ? AND kind = 'reply' AND closed_at IS NULL ORDER BY id`
|
|
138
|
+
)
|
|
139
|
+
.all(waiterId, targetId) as WaitRow[];
|
|
140
|
+
|
|
141
|
+
export interface WaitBadges {
|
|
142
|
+
on: { id: string; name: string; kind: WaitKind; deadline: number }[];
|
|
143
|
+
by: { id: string; name: string }[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Who each session waits on, and who waits on it, for the open waits.
|
|
147
|
+
export function waitBadges(): Map<string, WaitBadges> {
|
|
148
|
+
const rows = db
|
|
149
|
+
.prepare(
|
|
150
|
+
`SELECT w.waiter_id, w.target_id, w.kind, w.deadline,
|
|
151
|
+
COALESCE(a.name, 'a session') AS waiter_name,
|
|
152
|
+
COALESCE(b.name, 'a session') AS target_name
|
|
153
|
+
FROM bus_waits w
|
|
154
|
+
LEFT JOIN sessions a ON a.id = w.waiter_id
|
|
155
|
+
LEFT JOIN sessions b ON b.id = w.target_id
|
|
156
|
+
WHERE w.closed_at IS NULL ORDER BY w.id`
|
|
157
|
+
)
|
|
158
|
+
.all() as {
|
|
159
|
+
waiter_id: string;
|
|
160
|
+
target_id: string;
|
|
161
|
+
kind: WaitKind;
|
|
162
|
+
deadline: number;
|
|
163
|
+
waiter_name: string;
|
|
164
|
+
target_name: string;
|
|
165
|
+
}[];
|
|
166
|
+
const out = new Map<string, WaitBadges>();
|
|
167
|
+
const get = (id: string) => {
|
|
168
|
+
let b = out.get(id);
|
|
169
|
+
if (!b) out.set(id, (b = { on: [], by: [] }));
|
|
170
|
+
return b;
|
|
171
|
+
};
|
|
172
|
+
for (const r of rows) {
|
|
173
|
+
get(r.waiter_id).on.push({
|
|
174
|
+
id: r.target_id,
|
|
175
|
+
name: r.target_name,
|
|
176
|
+
kind: r.kind,
|
|
177
|
+
deadline: r.deadline,
|
|
178
|
+
});
|
|
179
|
+
get(r.target_id).by.push({ id: r.waiter_id, name: r.waiter_name });
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// "B", "B and C", "B, C +2": for a sidebar chip.
|
|
185
|
+
export function namesLine(names: string[]): string {
|
|
186
|
+
const unique = [...new Set(names)];
|
|
187
|
+
if (unique.length <= 2) return unique.join(" and ");
|
|
188
|
+
return `${unique.slice(0, 2).join(", ")} +${unique.length - 2}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Why a wait on this session can't be kept, or null.
|
|
192
|
+
export function cantWaitOn(to: Session): string | null {
|
|
193
|
+
if (isHeldRemote(to) || (to.peer_mirror && !to.task_prompt))
|
|
194
|
+
return "it runs on a linked machine, which can't tell this one when it answers";
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settles open waits: nudges a target that went idle without answering,
|
|
3
|
+
* wakes a waiter whose condition holds, whose deadline passed or whose
|
|
4
|
+
* target ended. Replies settle in sendMessage; this covers the rest. It
|
|
5
|
+
* reads status through statusOf, the same idle test the orchestrator uses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Session } from "../../db";
|
|
9
|
+
import { db } from "../../db";
|
|
10
|
+
import { statusDetector } from "../../status-detector";
|
|
11
|
+
import { statusOf } from "../../orchestrator/facts";
|
|
12
|
+
import { queueEventOnce } from "../../orchestrator/events";
|
|
13
|
+
import { sessionWorkspace } from "../../schedules/store";
|
|
14
|
+
import { sendMessage } from "../index";
|
|
15
|
+
import { onKick } from "./kick";
|
|
16
|
+
import {
|
|
17
|
+
claimClose,
|
|
18
|
+
claimNudge,
|
|
19
|
+
markSeenBusy,
|
|
20
|
+
openWaits,
|
|
21
|
+
type WaitRow,
|
|
22
|
+
} from "./store";
|
|
23
|
+
import {
|
|
24
|
+
closedPrLine,
|
|
25
|
+
endedLine,
|
|
26
|
+
nudgeLine,
|
|
27
|
+
orchestratorTimeoutLine,
|
|
28
|
+
satisfiedLine,
|
|
29
|
+
timeoutLine,
|
|
30
|
+
} from "./lines";
|
|
31
|
+
|
|
32
|
+
// A target that was never seen working is idle only this long after the
|
|
33
|
+
// wait opened: a chat turn can start and end between two looks, and a
|
|
34
|
+
// message may not have been picked up yet.
|
|
35
|
+
export const IDLE_GRACE_MS = 20_000;
|
|
36
|
+
// A terminal reads dead for a moment when tmux hiccups: twice in a row is gone.
|
|
37
|
+
const DEAD_LOOKS = 2;
|
|
38
|
+
const EVERY_MS = 5000;
|
|
39
|
+
|
|
40
|
+
const dead = new Map<string, number>();
|
|
41
|
+
|
|
42
|
+
const row = (id: string) =>
|
|
43
|
+
db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
|
|
44
|
+
Session | undefined;
|
|
45
|
+
|
|
46
|
+
// Why this session can no longer act on a wait, or null.
|
|
47
|
+
function endedHow(s: Session | undefined): string | null {
|
|
48
|
+
if (!s) return "deleted";
|
|
49
|
+
if (s.archived_at) return "archived";
|
|
50
|
+
if (s.task_status === "moved" || s.task_status === "moving") return "moved";
|
|
51
|
+
if (s.task_status && s.task_status !== "running") return s.task_status;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function tell(to: Session, line: string): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
await sendMessage({
|
|
58
|
+
fromId: null,
|
|
59
|
+
fromLabel: "AgentOS",
|
|
60
|
+
to: to.id,
|
|
61
|
+
body: line,
|
|
62
|
+
line,
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`[waits] couldn't tell ${to.name}:`, error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function settle(w: WaitRow, waiter: Session, now: number): Promise<void> {
|
|
70
|
+
const target = row(w.target_id);
|
|
71
|
+
const end = endedHow(target);
|
|
72
|
+
const prMerged =
|
|
73
|
+
target?.pr_status === "merged" || target?.task_status === "merged";
|
|
74
|
+
|
|
75
|
+
if (w.kind === "merged" && prMerged) {
|
|
76
|
+
if (claimClose(w.id, "merged", null, now))
|
|
77
|
+
await tell(waiter, satisfiedLine(target!, "merged", target!.pr_number));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (w.kind === "merged" && target?.pr_status === "closed") {
|
|
81
|
+
if (claimClose(w.id, "ended", null, now))
|
|
82
|
+
await tell(waiter, closedPrLine(target, target.pr_number));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (end || !target) {
|
|
86
|
+
if (claimClose(w.id, "ended", null, now))
|
|
87
|
+
await tell(
|
|
88
|
+
waiter,
|
|
89
|
+
endedLine(
|
|
90
|
+
target ?? { id: w.target_id, name: "the session" },
|
|
91
|
+
end ?? "deleted",
|
|
92
|
+
w.request
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { status } = await statusOf(target);
|
|
99
|
+
if (status === "dead") {
|
|
100
|
+
const n = (dead.get(target.id) ?? 0) + 1;
|
|
101
|
+
dead.set(target.id, n);
|
|
102
|
+
if (n >= DEAD_LOOKS) {
|
|
103
|
+
if (claimClose(w.id, "ended", null, now))
|
|
104
|
+
await tell(
|
|
105
|
+
waiter,
|
|
106
|
+
endedLine(target, "its terminal is gone", w.request)
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
} else dead.delete(target.id);
|
|
111
|
+
|
|
112
|
+
if (status === "running") markSeenBusy(w.id, now);
|
|
113
|
+
const idle =
|
|
114
|
+
status === "idle" &&
|
|
115
|
+
(w.seen_busy_at != null || now - w.created_at >= IDLE_GRACE_MS);
|
|
116
|
+
|
|
117
|
+
if (w.kind === "idle" && idle) {
|
|
118
|
+
if (claimClose(w.id, "idle", null, now))
|
|
119
|
+
await tell(waiter, satisfiedLine(target, "idle"));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (now >= w.deadline) {
|
|
124
|
+
if (!claimClose(w.id, "timeout", null, now)) return;
|
|
125
|
+
const is = status === "running" ? "still working" : status;
|
|
126
|
+
await tell(
|
|
127
|
+
waiter,
|
|
128
|
+
timeoutLine(target, w.kind, w.deadline - w.created_at, w.request, is)
|
|
129
|
+
);
|
|
130
|
+
const workspace = sessionWorkspace(waiter);
|
|
131
|
+
if (workspace && waiter.role !== "orchestrator")
|
|
132
|
+
queueEventOnce(
|
|
133
|
+
workspace,
|
|
134
|
+
`wait-timeout:${w.id}`,
|
|
135
|
+
waiter.id,
|
|
136
|
+
orchestratorTimeoutLine(
|
|
137
|
+
waiter.name,
|
|
138
|
+
target.name,
|
|
139
|
+
w.deadline - w.created_at,
|
|
140
|
+
w.request
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (w.kind === "reply" && idle && !w.nudged_at && claimNudge(w.id, now))
|
|
147
|
+
await tell(target, nudgeLine(waiter, w.request, w.message_id));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let running = false;
|
|
151
|
+
|
|
152
|
+
export async function waitsTick(now = Date.now()): Promise<void> {
|
|
153
|
+
if (running) return;
|
|
154
|
+
running = true;
|
|
155
|
+
try {
|
|
156
|
+
const waits = openWaits();
|
|
157
|
+
if (!waits.length) return;
|
|
158
|
+
await statusDetector.refreshCache();
|
|
159
|
+
for (const w of waits) {
|
|
160
|
+
try {
|
|
161
|
+
const waiter = row(w.waiter_id);
|
|
162
|
+
// A waiter that is gone has no one to tell.
|
|
163
|
+
if (!waiter || endedHow(waiter)) {
|
|
164
|
+
claimClose(w.id, "cancelled", null, now);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
await settle(w, waiter, now);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error(`[waits] wait ${w.id}:`, error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
running = false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let timer: NodeJS.Timeout | null = null;
|
|
178
|
+
let kicked = false;
|
|
179
|
+
|
|
180
|
+
export function startWaitWatcher(): void {
|
|
181
|
+
if (timer) return;
|
|
182
|
+
timer = setInterval(() => void waitsTick(), EVERY_MS);
|
|
183
|
+
timer.unref();
|
|
184
|
+
onKick(() => {
|
|
185
|
+
if (kicked) return;
|
|
186
|
+
kicked = true;
|
|
187
|
+
setImmediate(() => {
|
|
188
|
+
kicked = false;
|
|
189
|
+
void waitsTick();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -214,17 +214,123 @@ export async function requestTunnel(
|
|
|
214
214
|
return { hostname: hostnameFrom(r), token: tokenFrom(r) };
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/** What the account's plan allows; absent from an older Connect service. */
|
|
218
|
+
export interface Entitlements {
|
|
219
|
+
plan: string;
|
|
220
|
+
linkMachines: boolean;
|
|
221
|
+
machineLimit: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface Beat {
|
|
225
|
+
enabled: boolean;
|
|
226
|
+
entitlements?: Entitlements;
|
|
227
|
+
/** Changes whenever the list `peers` would give changes. */
|
|
228
|
+
peersEtag?: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const ETAG = /^[A-Za-z0-9_-]{1,128}$/;
|
|
232
|
+
|
|
233
|
+
export function entitlementsFrom(raw: unknown): Entitlements | undefined {
|
|
234
|
+
const e = (raw ?? null) as Record<string, unknown> | null;
|
|
235
|
+
if (!e || typeof e !== "object" || typeof e.link_machines !== "boolean")
|
|
236
|
+
return undefined;
|
|
237
|
+
return {
|
|
238
|
+
plan: typeof e.plan === "string" ? clean(e.plan, 32) : "unknown",
|
|
239
|
+
linkMachines: e.link_machines,
|
|
240
|
+
machineLimit:
|
|
241
|
+
typeof e.machine_limit === "number" &&
|
|
242
|
+
Number.isSafeInteger(e.machine_limit)
|
|
243
|
+
? e.machine_limit
|
|
244
|
+
: 0,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const etagFrom = (raw: unknown) =>
|
|
249
|
+
typeof raw === "string" && ETAG.test(raw) ? raw : undefined;
|
|
250
|
+
|
|
217
251
|
export async function heartbeat(
|
|
218
252
|
config: ConnectConfig,
|
|
219
253
|
privateKey: string,
|
|
220
254
|
version: string
|
|
221
|
-
) {
|
|
255
|
+
): Promise<Beat> {
|
|
222
256
|
const res = await signed(config, privateKey)(
|
|
223
257
|
"POST",
|
|
224
258
|
`/api/v1/machines/${config.machineId}/heartbeat`,
|
|
225
259
|
{ version }
|
|
226
260
|
);
|
|
227
|
-
|
|
261
|
+
const r = ((await res.json().catch(() => null)) ?? {}) as Record<
|
|
262
|
+
string,
|
|
263
|
+
unknown
|
|
264
|
+
>;
|
|
265
|
+
return {
|
|
266
|
+
enabled: r.enabled !== false,
|
|
267
|
+
entitlements: entitlementsFrom(r.entitlements),
|
|
268
|
+
peersEtag: etagFrom(r.peers_etag),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Another machine on this account, as the Connect service introduces it. */
|
|
273
|
+
export interface ListedMachine {
|
|
274
|
+
id: string;
|
|
275
|
+
name: string;
|
|
276
|
+
hostname: string;
|
|
277
|
+
/** Its Ed25519 key as the service holds it: SPKI PEM. */
|
|
278
|
+
publicKey: string;
|
|
279
|
+
lastSeenAt: string | null;
|
|
280
|
+
version: string | null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface PeerList {
|
|
284
|
+
entitlements?: Entitlements;
|
|
285
|
+
peersEtag?: string;
|
|
286
|
+
peers: ListedMachine[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const MAX_PEERS = 100;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The account's other machines this one may link to. Only an introduction:
|
|
293
|
+
* anything malformed is dropped here, and the keys still have to be
|
|
294
|
+
* confirmed by a person on both machines before either trusts the other.
|
|
295
|
+
*/
|
|
296
|
+
export async function listPeers(
|
|
297
|
+
config: ConnectConfig,
|
|
298
|
+
privateKey: string
|
|
299
|
+
): Promise<PeerList> {
|
|
300
|
+
const res = await signed(config, privateKey)(
|
|
301
|
+
"GET",
|
|
302
|
+
`/api/v1/machines/${config.machineId}/peers`
|
|
303
|
+
);
|
|
304
|
+
const r = (await res.json().catch(() => null)) as Record<
|
|
305
|
+
string,
|
|
306
|
+
unknown
|
|
307
|
+
> | null;
|
|
308
|
+
if (!r || !Array.isArray(r.peers))
|
|
309
|
+
throw new Error("the Connect service sent no machine list");
|
|
310
|
+
const str = (v: unknown, max: number) =>
|
|
311
|
+
typeof v === "string" && v.length <= max ? v : null;
|
|
312
|
+
const peers: ListedMachine[] = [];
|
|
313
|
+
for (const raw of r.peers.slice(0, MAX_PEERS)) {
|
|
314
|
+
const m = (raw ?? {}) as Record<string, unknown>;
|
|
315
|
+
const id = str(m.id, 8);
|
|
316
|
+
const hostname = str(m.hostname, 253);
|
|
317
|
+
const publicKey = str(m.public_key, 1024);
|
|
318
|
+
if (!id || !MACHINE_ID.test(id) || !hostname || !publicKey) continue;
|
|
319
|
+
if (id === config.machineId) continue;
|
|
320
|
+
peers.push({
|
|
321
|
+
id,
|
|
322
|
+
name: clean(str(m.name, 500) ?? id, 80),
|
|
323
|
+
hostname,
|
|
324
|
+
publicKey,
|
|
325
|
+
lastSeenAt: str(m.last_seen_at, 64),
|
|
326
|
+
version: str(m.agent_os_version, 64),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
entitlements: entitlementsFrom(r.entitlements),
|
|
331
|
+
peersEtag: etagFrom(r.peers_etag),
|
|
332
|
+
peers,
|
|
333
|
+
};
|
|
228
334
|
}
|
|
229
335
|
|
|
230
336
|
export interface PushRequestBody {
|
package/lib/connect/heartbeat.ts
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from "fs";
|
|
9
9
|
import path from "path";
|
|
10
|
-
import { ApiError, clean } from "./control-api";
|
|
10
|
+
import { ApiError, clean, type Beat } from "./control-api";
|
|
11
11
|
|
|
12
12
|
export const HEARTBEAT_MS = 60_000;
|
|
13
13
|
|
|
14
14
|
export interface HeartbeatOptions {
|
|
15
|
-
beat: () => Promise<
|
|
15
|
+
beat: () => Promise<Beat>;
|
|
16
|
+
/** Every answer, for what it says about the account (plan, peers). */
|
|
17
|
+
onBeat?: (beat: Beat) => void;
|
|
16
18
|
/** The account disabled this machine: stop the tunnel. */
|
|
17
19
|
onDisabled: () => void;
|
|
18
20
|
/** The service no longer knows this machine. */
|
|
@@ -43,9 +45,15 @@ export function startHeartbeat(opts: HeartbeatOptions) {
|
|
|
43
45
|
const tick = async () => {
|
|
44
46
|
timer = null;
|
|
45
47
|
try {
|
|
46
|
-
const
|
|
48
|
+
const answer = await opts.beat();
|
|
49
|
+
const { enabled } = answer;
|
|
47
50
|
if (stopped) return;
|
|
48
51
|
failing = false;
|
|
52
|
+
try {
|
|
53
|
+
opts.onBeat?.(answer);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
log(`connect: ${clean((err as Error)?.message ?? String(err))}`);
|
|
56
|
+
}
|
|
49
57
|
if (enabled === false) {
|
|
50
58
|
stop();
|
|
51
59
|
log("connect: this machine was disabled in your account; tunnel off");
|
package/lib/connect/serve.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type Tunnel,
|
|
41
41
|
} from "./control-api";
|
|
42
42
|
import { appVersion, startHeartbeat } from "./heartbeat";
|
|
43
|
+
import { noteHeartbeat } from "@/lib/peer/account";
|
|
43
44
|
import { ensureCloudflared } from "./cloudflared";
|
|
44
45
|
import {
|
|
45
46
|
reapTunnel,
|
|
@@ -331,6 +332,7 @@ export function startConnect(
|
|
|
331
332
|
const version = appVersion();
|
|
332
333
|
const heartbeat = startHeartbeat({
|
|
333
334
|
beat: () => beat(config, machineKey, version),
|
|
335
|
+
onBeat: noteHeartbeat,
|
|
334
336
|
log,
|
|
335
337
|
intervalMs: deps.heartbeatMs,
|
|
336
338
|
onDisabled: () => {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261020T1258_peer_links",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// A machine linked through Connect (lib/peer): its pinned keys, never a
|
|
7
|
+
// token. Foreign keys aren't enforced here, so deleteHost removes the pin.
|
|
8
|
+
db.exec(`CREATE TABLE IF NOT EXISTS peer_links (
|
|
9
|
+
host_id TEXT PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
|
|
10
|
+
machine_id TEXT NOT NULL UNIQUE,
|
|
11
|
+
hostname TEXT NOT NULL,
|
|
12
|
+
sign_pub TEXT NOT NULL,
|
|
13
|
+
box_pub TEXT NOT NULL,
|
|
14
|
+
state TEXT NOT NULL DEFAULT 'active',
|
|
15
|
+
suspended_reason TEXT,
|
|
16
|
+
paired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
17
|
+
last_ok_at TEXT,
|
|
18
|
+
last_error TEXT
|
|
19
|
+
)`);
|
|
20
|
+
// Every peer call, both directions. Successful polling reads fold into
|
|
21
|
+
// one row per route per hour (`bucket`, `count`).
|
|
22
|
+
db.exec(`CREATE TABLE IF NOT EXISTS peer_audit (
|
|
23
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
24
|
+
at INTEGER NOT NULL,
|
|
25
|
+
host_id TEXT,
|
|
26
|
+
machine_id TEXT,
|
|
27
|
+
direction TEXT NOT NULL,
|
|
28
|
+
method TEXT NOT NULL,
|
|
29
|
+
route TEXT NOT NULL,
|
|
30
|
+
status INTEGER NOT NULL,
|
|
31
|
+
ms INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
bytes INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
outcome TEXT NOT NULL,
|
|
34
|
+
bucket TEXT,
|
|
35
|
+
count INTEGER NOT NULL DEFAULT 1
|
|
36
|
+
)`);
|
|
37
|
+
db.exec(
|
|
38
|
+
`CREATE INDEX IF NOT EXISTS idx_peer_audit_host_at ON peer_audit(host_id, at)`
|
|
39
|
+
);
|
|
40
|
+
db.exec(
|
|
41
|
+
`CREATE INDEX IF NOT EXISTS idx_peer_audit_bucket ON peer_audit(host_id, direction, route, bucket)`
|
|
42
|
+
);
|
|
43
|
+
const cols = db.prepare(`PRAGMA table_info(hosts)`).all() as {
|
|
44
|
+
name: string;
|
|
45
|
+
}[];
|
|
46
|
+
if (!cols.some((c) => c.name === "kind"))
|
|
47
|
+
db.exec(`ALTER TABLE hosts ADD COLUMN kind TEXT NOT NULL DEFAULT 'ssh'`);
|
|
48
|
+
},
|
|
49
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261020T1345_peer_nonces",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// Nonces of linked machines' envelopes (lib/peer/replay.ts), kept until
|
|
7
|
+
// their timestamp would be stale anyway, so a restart can't replay one.
|
|
8
|
+
db.exec(`CREATE TABLE IF NOT EXISTS peer_nonces (
|
|
9
|
+
machine_id TEXT NOT NULL,
|
|
10
|
+
nonce TEXT NOT NULL,
|
|
11
|
+
until INTEGER NOT NULL,
|
|
12
|
+
PRIMARY KEY (machine_id, nonce)
|
|
13
|
+
)`);
|
|
14
|
+
db.exec(
|
|
15
|
+
`CREATE INDEX IF NOT EXISTS idx_peer_nonces_until ON peer_nonces(until)`
|
|
16
|
+
);
|
|
17
|
+
},
|
|
18
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261020T1600_bus_waits",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// A session waiting on another (lib/bus/waits): for its reply, or for it
|
|
7
|
+
// to go idle or merge. Closed rows stay as history.
|
|
8
|
+
db.exec(`CREATE TABLE IF NOT EXISTS bus_waits (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
waiter_id TEXT NOT NULL,
|
|
11
|
+
target_id TEXT NOT NULL,
|
|
12
|
+
kind TEXT NOT NULL,
|
|
13
|
+
message_id INTEGER,
|
|
14
|
+
request TEXT,
|
|
15
|
+
deadline INTEGER NOT NULL,
|
|
16
|
+
created_at INTEGER NOT NULL,
|
|
17
|
+
seen_busy_at INTEGER,
|
|
18
|
+
nudged_at INTEGER,
|
|
19
|
+
closed_at INTEGER,
|
|
20
|
+
outcome TEXT,
|
|
21
|
+
reply_id INTEGER
|
|
22
|
+
)`);
|
|
23
|
+
db.exec(
|
|
24
|
+
`CREATE INDEX IF NOT EXISTS idx_bus_waits_open ON bus_waits(target_id) WHERE closed_at IS NULL`
|
|
25
|
+
);
|
|
26
|
+
db.exec(
|
|
27
|
+
`CREATE INDEX IF NOT EXISTS idx_bus_waits_waiter ON bus_waits(waiter_id) WHERE closed_at IS NULL`
|
|
28
|
+
);
|
|
29
|
+
const cols = db.prepare(`PRAGMA table_info(bus_messages)`).all() as {
|
|
30
|
+
name: string;
|
|
31
|
+
}[];
|
|
32
|
+
if (!cols.some((c) => c.name === "reply_to"))
|
|
33
|
+
db.exec(`ALTER TABLE bus_messages ADD COLUMN reply_to INTEGER`);
|
|
34
|
+
},
|
|
35
|
+
} satisfies Migration;
|
package/lib/db/types.ts
CHANGED
|
@@ -90,6 +90,10 @@ export interface Session {
|
|
|
90
90
|
pause_id?: number | null;
|
|
91
91
|
// Why a requested resume hasn't happened yet.
|
|
92
92
|
pause_note?: string | null;
|
|
93
|
+
// Set only by GET /api/sessions: the sessions it waits on, and who waits on
|
|
94
|
+
// it, as a short line (lib/bus/waits/store).
|
|
95
|
+
waiting_on?: string | null;
|
|
96
|
+
waited_by?: string | null;
|
|
93
97
|
placed_note?: string | null;
|
|
94
98
|
// Set by done: hidden from the sidebar and the orchestrator, never deleted.
|
|
95
99
|
archived_at: string | null;
|
|
@@ -306,6 +310,8 @@ export interface Host {
|
|
|
306
310
|
ssh_target: string;
|
|
307
311
|
sort_order: number;
|
|
308
312
|
created_at: string;
|
|
313
|
+
// 'connect': reached only through Connect (lib/peer), ssh_target is ''.
|
|
314
|
+
kind?: "ssh" | "connect";
|
|
309
315
|
// Paired with that machine's own AgentOS (lib/hosts/link.ts); 0/1 from SQLite.
|
|
310
316
|
linked?: boolean | number;
|
|
311
317
|
}
|