@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
package/lib/done/archive.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import fs from "fs";
|
|
5
5
|
import { db, queries, type Session } from "../db";
|
|
6
6
|
import { statusOf } from "../orchestrator/facts";
|
|
7
|
+
import { kickWaits } from "../bus/waits/kick";
|
|
7
8
|
import { peerSessionLink } from "../hosts/peer-actions";
|
|
8
9
|
|
|
9
10
|
export interface ArchivedView {
|
|
@@ -26,6 +27,7 @@ export function archiveSession(id: string): void {
|
|
|
26
27
|
db.prepare(
|
|
27
28
|
`UPDATE sessions SET archived_at = datetime('now') WHERE id = ? AND archived_at IS NULL`
|
|
28
29
|
).run(id);
|
|
30
|
+
kickWaits();
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export function unarchiveSession(id: string): Session {
|
package/lib/hosts/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const LOCAL_HOST: Host = {
|
|
|
14
14
|
id: LOCAL_HOST_ID,
|
|
15
15
|
name: "This machine",
|
|
16
16
|
ssh_target: "",
|
|
17
|
+
kind: "ssh",
|
|
17
18
|
sort_order: -1,
|
|
18
19
|
created_at: "",
|
|
19
20
|
};
|
|
@@ -25,7 +26,10 @@ export function isRemoteHost(hostId: string | null | undefined): boolean {
|
|
|
25
26
|
export function listRemoteHosts(): Host[] {
|
|
26
27
|
return getDb()
|
|
27
28
|
.prepare(
|
|
28
|
-
`SELECT h.*,
|
|
29
|
+
`SELECT h.*, CASE WHEN h.kind = 'connect'
|
|
30
|
+
THEN EXISTS (SELECT 1 FROM peer_links p WHERE p.host_id = h.id)
|
|
31
|
+
ELSE EXISTS (SELECT 1 FROM host_links l WHERE l.host_id = h.id)
|
|
32
|
+
END AS linked
|
|
29
33
|
FROM hosts h ORDER BY sort_order, created_at`
|
|
30
34
|
)
|
|
31
35
|
.all() as Host[];
|
|
@@ -54,22 +58,34 @@ export function hostIdNamed(ref: string): string {
|
|
|
54
58
|
return host.id;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
|
|
61
|
+
function insertHost(
|
|
62
|
+
name: string,
|
|
63
|
+
sshTarget: string,
|
|
64
|
+
kind: "ssh" | "connect"
|
|
65
|
+
): Host {
|
|
58
66
|
if (!name.trim()) throw new Error("Name is required");
|
|
59
|
-
if (!isValidSshTarget(sshTarget)) {
|
|
60
|
-
throw new Error("ssh target must look like user@host or an ssh alias");
|
|
61
|
-
}
|
|
62
67
|
const db = getDb();
|
|
63
68
|
const id = randomUUID();
|
|
64
69
|
const { max } = db
|
|
65
70
|
.prepare(`SELECT COALESCE(MAX(sort_order), 0) AS max FROM hosts`)
|
|
66
71
|
.get() as { max: number };
|
|
67
72
|
db.prepare(
|
|
68
|
-
`INSERT INTO hosts (id, name, ssh_target, sort_order) VALUES (?, ?, ?, ?)`
|
|
69
|
-
).run(id, name.trim(), sshTarget, max + 1);
|
|
73
|
+
`INSERT INTO hosts (id, name, ssh_target, sort_order, kind) VALUES (?, ?, ?, ?, ?)`
|
|
74
|
+
).run(id, name.trim(), sshTarget, max + 1, kind);
|
|
70
75
|
return getHost(id)!;
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
export function createHost(name: string, sshTarget: string): Host {
|
|
79
|
+
if (!isValidSshTarget(sshTarget)) {
|
|
80
|
+
throw new Error("ssh target must look like user@host or an ssh alias");
|
|
81
|
+
}
|
|
82
|
+
return insertHost(name, sshTarget, "ssh");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A machine reached only through Connect; its keys are pinned separately. */
|
|
86
|
+
export const createConnectHost = (name: string): Host =>
|
|
87
|
+
insertHost(name, "", "connect");
|
|
88
|
+
|
|
73
89
|
export function deleteHost(hostId: string): void {
|
|
74
90
|
if (!isRemoteHost(hostId)) throw new Error("Cannot remove this machine");
|
|
75
91
|
const db = getDb();
|
|
@@ -83,6 +99,7 @@ export function deleteHost(hostId: string): void {
|
|
|
83
99
|
throw new Error("Move or delete its projects and sessions first");
|
|
84
100
|
}
|
|
85
101
|
db.prepare(`DELETE FROM host_links WHERE host_id = ?`).run(hostId);
|
|
102
|
+
db.prepare(`DELETE FROM peer_links WHERE host_id = ?`).run(hostId);
|
|
86
103
|
db.prepare(`DELETE FROM hosts WHERE id = ?`).run(hostId);
|
|
87
104
|
}
|
|
88
105
|
|
|
@@ -90,6 +107,10 @@ export function sshTargetFor(hostId: string | null | undefined): string | null {
|
|
|
90
107
|
if (!isRemoteHost(hostId)) return null;
|
|
91
108
|
const host = getHost(hostId);
|
|
92
109
|
if (!host) throw new Error(`Unknown host: ${hostId}`);
|
|
110
|
+
if (host.kind === "connect")
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${host.name} is linked through Connect: this needs a Tailscale/ssh link`
|
|
113
|
+
);
|
|
93
114
|
return host.ssh_target;
|
|
94
115
|
}
|
|
95
116
|
|
package/lib/hosts/peer-socket.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { WebSocket } from "ws";
|
|
10
|
-
import type
|
|
10
|
+
import { requireTokenLink, type HostLink } from "./remote-api";
|
|
11
11
|
|
|
12
12
|
export const PEER_HANDSHAKE_MS = 10_000;
|
|
13
13
|
// A long chat's (paged) snapshot is one message.
|
|
@@ -30,6 +30,8 @@ export function openPeerSocket(
|
|
|
30
30
|
query: Record<string, string> = {},
|
|
31
31
|
maxPayload = PEER_MAX_PAYLOAD
|
|
32
32
|
): WebSocket {
|
|
33
|
+
// A Connect link has no token, and sockets don't ride the envelope yet.
|
|
34
|
+
requireTokenLink(link);
|
|
33
35
|
return new WebSocket(peerSocketUrl(link, path, query), {
|
|
34
36
|
headers: { Authorization: `Bearer ${link.token}` },
|
|
35
37
|
handshakeTimeout: PEER_HANDSHAKE_MS,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** A response body, read no further than max bytes whatever it claims. */
|
|
2
|
+
export async function readCappedBytes(
|
|
3
|
+
res: Response,
|
|
4
|
+
max: number
|
|
5
|
+
): Promise<Buffer> {
|
|
6
|
+
if (Number(res.headers.get("content-length")) > max)
|
|
7
|
+
throw new Error("its answer is too large");
|
|
8
|
+
const reader = res.body?.getReader();
|
|
9
|
+
if (!reader) return Buffer.alloc(0);
|
|
10
|
+
const chunks: Uint8Array[] = [];
|
|
11
|
+
let total = 0;
|
|
12
|
+
for (;;) {
|
|
13
|
+
const { done, value } = await reader.read();
|
|
14
|
+
if (done) break;
|
|
15
|
+
total += value.byteLength;
|
|
16
|
+
if (total > max) {
|
|
17
|
+
await reader.cancel().catch(() => undefined);
|
|
18
|
+
throw new Error("its answer is too large");
|
|
19
|
+
}
|
|
20
|
+
chunks.push(value);
|
|
21
|
+
}
|
|
22
|
+
return Buffer.concat(chunks);
|
|
23
|
+
}
|
package/lib/hosts/remote-api.ts
CHANGED
|
@@ -4,38 +4,112 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getDb } from "../db";
|
|
7
|
+
import { PeerCallError, peerCall } from "../peer/client";
|
|
8
|
+
import type { PinState } from "../peer/links";
|
|
7
9
|
import { getHost, isRemoteHost } from "./index";
|
|
10
|
+
import { readCappedBytes } from "./read-capped-bytes";
|
|
8
11
|
|
|
12
|
+
/** A machine linked through Connect: its pinned keys (lib/peer). */
|
|
13
|
+
export interface ConnectPeer {
|
|
14
|
+
machineId: string;
|
|
15
|
+
hostname: string;
|
|
16
|
+
signPub: string;
|
|
17
|
+
boxPub: string;
|
|
18
|
+
state: PinState;
|
|
19
|
+
suspendedReason: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `token` (the default): a device token over Tailscale or ssh-reached URLs.
|
|
24
|
+
* `connect`: signed, encrypted envelopes to the pinned keys; url and token
|
|
25
|
+
* are unused (token is always empty).
|
|
26
|
+
*/
|
|
9
27
|
export interface HostLink {
|
|
10
28
|
hostId: string;
|
|
11
29
|
hostName: string;
|
|
12
30
|
url: string;
|
|
13
31
|
token: string;
|
|
32
|
+
transport?: "token" | "connect";
|
|
33
|
+
connect?: ConnectPeer;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface LinkRow {
|
|
37
|
+
host_id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
kind: string;
|
|
40
|
+
url: string | null;
|
|
41
|
+
token: string | null;
|
|
42
|
+
machine_id: string | null;
|
|
43
|
+
hostname: string | null;
|
|
44
|
+
sign_pub: string | null;
|
|
45
|
+
box_pub: string | null;
|
|
46
|
+
state: string | null;
|
|
47
|
+
suspended_reason: string | null;
|
|
14
48
|
}
|
|
15
49
|
|
|
16
50
|
export function hostLink(hostId: string | null | undefined): HostLink | null {
|
|
17
51
|
if (!isRemoteHost(hostId)) return null;
|
|
18
52
|
const row = getDb()
|
|
19
53
|
.prepare(
|
|
20
|
-
`SELECT
|
|
21
|
-
|
|
54
|
+
`SELECT h.id AS host_id, h.name, h.kind, l.url, l.token, p.machine_id,
|
|
55
|
+
p.hostname, p.sign_pub, p.box_pub, p.state, p.suspended_reason
|
|
56
|
+
FROM hosts h
|
|
57
|
+
LEFT JOIN host_links l ON l.host_id = h.id
|
|
58
|
+
LEFT JOIN peer_links p ON p.host_id = h.id
|
|
59
|
+
WHERE h.id = ?`
|
|
22
60
|
)
|
|
23
|
-
.get(hostId) as
|
|
24
|
-
|
|
25
|
-
|
|
61
|
+
.get(hostId) as LinkRow | undefined;
|
|
62
|
+
if (!row) return null;
|
|
63
|
+
if (row.kind === "connect") {
|
|
64
|
+
if (!row.machine_id || !row.hostname || !row.sign_pub || !row.box_pub)
|
|
65
|
+
return null;
|
|
66
|
+
return {
|
|
67
|
+
hostId: row.host_id,
|
|
68
|
+
hostName: row.name,
|
|
69
|
+
url: `https://${row.hostname}`,
|
|
70
|
+
token: "",
|
|
71
|
+
transport: "connect",
|
|
72
|
+
connect: {
|
|
73
|
+
machineId: row.machine_id,
|
|
74
|
+
hostname: row.hostname,
|
|
75
|
+
signPub: row.sign_pub,
|
|
76
|
+
boxPub: row.box_pub,
|
|
77
|
+
state: row.state === "active" ? "active" : "suspended",
|
|
78
|
+
suspendedReason: row.suspended_reason,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return row.url && row.token
|
|
26
83
|
? {
|
|
27
84
|
hostId: row.host_id,
|
|
28
85
|
hostName: row.name,
|
|
29
86
|
url: row.url,
|
|
30
87
|
token: row.token,
|
|
88
|
+
transport: "token",
|
|
31
89
|
}
|
|
32
90
|
: null;
|
|
33
91
|
}
|
|
34
92
|
|
|
93
|
+
export const isConnectLink = (link: HostLink) => link.transport === "connect";
|
|
94
|
+
|
|
95
|
+
/** For the calls that only work with a token link (sockets, raw fetches). */
|
|
96
|
+
export function requireTokenLink(link: HostLink): HostLink {
|
|
97
|
+
if (isConnectLink(link))
|
|
98
|
+
throw new Error(
|
|
99
|
+
`${link.hostName} is linked through Connect: this needs a Tailscale/ssh link for now`
|
|
100
|
+
);
|
|
101
|
+
return link;
|
|
102
|
+
}
|
|
103
|
+
|
|
35
104
|
export function linkedHostIds(): Set<string> {
|
|
36
|
-
const rows = getDb()
|
|
37
|
-
|
|
38
|
-
|
|
105
|
+
const rows = getDb()
|
|
106
|
+
.prepare(
|
|
107
|
+
`SELECT host_id FROM host_links
|
|
108
|
+
WHERE host_id NOT IN (SELECT id FROM hosts WHERE kind = 'connect')
|
|
109
|
+
UNION SELECT p.host_id FROM peer_links p JOIN hosts h ON h.id = p.host_id
|
|
110
|
+
WHERE h.kind = 'connect'`
|
|
111
|
+
)
|
|
112
|
+
.all() as { host_id: string }[];
|
|
39
113
|
return new Set(rows.map((r) => r.host_id));
|
|
40
114
|
}
|
|
41
115
|
|
|
@@ -103,23 +177,7 @@ export async function readCapped(
|
|
|
103
177
|
res: Response,
|
|
104
178
|
max = MAX_RESPONSE_BYTES
|
|
105
179
|
): Promise<string> {
|
|
106
|
-
|
|
107
|
-
throw new Error("its answer is too large");
|
|
108
|
-
const reader = res.body?.getReader();
|
|
109
|
-
if (!reader) return "";
|
|
110
|
-
const chunks: Uint8Array[] = [];
|
|
111
|
-
let total = 0;
|
|
112
|
-
for (;;) {
|
|
113
|
-
const { done, value } = await reader.read();
|
|
114
|
-
if (done) break;
|
|
115
|
-
total += value.byteLength;
|
|
116
|
-
if (total > max) {
|
|
117
|
-
await reader.cancel().catch(() => undefined);
|
|
118
|
-
throw new Error("its answer is too large");
|
|
119
|
-
}
|
|
120
|
-
chunks.push(value);
|
|
121
|
-
}
|
|
122
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
180
|
+
return (await readCappedBytes(res, max)).toString("utf8");
|
|
123
181
|
}
|
|
124
182
|
|
|
125
183
|
/** A JSON call to the other machine's AgentOS; its error text on failure. */
|
|
@@ -133,21 +191,34 @@ export async function hostApi<T>(
|
|
|
133
191
|
`Can't reach AgentOS on ${link.hostName}: ${cleanRemoteText(err instanceof Error ? err.message : err)}`,
|
|
134
192
|
false
|
|
135
193
|
);
|
|
136
|
-
let
|
|
194
|
+
let status: number;
|
|
137
195
|
let text: string;
|
|
138
196
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
197
|
+
if (isConnectLink(link)) {
|
|
198
|
+
({ status, text } = await peerCall(link, path, opts));
|
|
199
|
+
} else {
|
|
200
|
+
const res = await fetch(`${link.url}${path}`, {
|
|
201
|
+
method: opts.method ?? (opts.body === undefined ? "GET" : "POST"),
|
|
202
|
+
headers: {
|
|
203
|
+
Authorization: `Bearer ${link.token}`,
|
|
204
|
+
...(opts.body !== undefined && {
|
|
205
|
+
"Content-Type": "application/json",
|
|
206
|
+
}),
|
|
207
|
+
},
|
|
208
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
209
|
+
signal: AbortSignal.timeout(opts.timeout ?? 15000),
|
|
210
|
+
redirect: "error",
|
|
211
|
+
});
|
|
212
|
+
status = res.status;
|
|
213
|
+
text = await readCapped(res);
|
|
214
|
+
}
|
|
150
215
|
} catch (err) {
|
|
216
|
+
if (err instanceof PeerCallError && err.refused)
|
|
217
|
+
throw new HostApiError(
|
|
218
|
+
`${link.hostName}: ${cleanRemoteText(err.message)}`,
|
|
219
|
+
true,
|
|
220
|
+
err.status
|
|
221
|
+
);
|
|
151
222
|
throw unknown(err);
|
|
152
223
|
}
|
|
153
224
|
let data: { error?: unknown } | null = null;
|
|
@@ -156,13 +227,13 @@ export async function hostApi<T>(
|
|
|
156
227
|
} catch {
|
|
157
228
|
data = null;
|
|
158
229
|
}
|
|
159
|
-
if (
|
|
230
|
+
if (status < 200 || status > 299) {
|
|
160
231
|
// Only AgentOS's own refusal (a 4xx with its error) means nothing happened.
|
|
161
|
-
const refused =
|
|
232
|
+
const refused = status >= 400 && status < 500 && !!data?.error;
|
|
162
233
|
throw new HostApiError(
|
|
163
|
-
`${link.hostName}: ${cleanRemoteText(data?.error) || `HTTP ${
|
|
234
|
+
`${link.hostName}: ${cleanRemoteText(data?.error) || `HTTP ${status}`}`,
|
|
164
235
|
refused,
|
|
165
|
-
|
|
236
|
+
status
|
|
166
237
|
);
|
|
167
238
|
}
|
|
168
239
|
if (!data) throw unknown(new Error("its answer wasn't JSON"));
|
|
@@ -41,6 +41,19 @@ function sessionLine(f: SessionFacts): string {
|
|
|
41
41
|
parts.push(
|
|
42
42
|
`${f.plan.awaiting ? "plan ready, awaiting your approve_plan" : f.plan.planned ? "planned" : "no plan"}${f.plan.reason ? `: ${untrusted(f.name, f.plan.reason)}` : ""}`
|
|
43
43
|
);
|
|
44
|
+
if (f.waits?.on.length)
|
|
45
|
+
parts.push(
|
|
46
|
+
`waiting on ${f.waits.on
|
|
47
|
+
.map(
|
|
48
|
+
(w) =>
|
|
49
|
+
`${untrusted(f.name, w.name)} (${w.kind === "reply" ? "reply" : w.kind === "idle" ? "to go idle" : "to merge"}, ${Math.max(1, Math.round(w.leftMs / 60_000))}m left)`
|
|
50
|
+
)
|
|
51
|
+
.join(", ")}`
|
|
52
|
+
);
|
|
53
|
+
if (f.waits?.by.length)
|
|
54
|
+
parts.push(
|
|
55
|
+
`${f.waits.by.map((n) => untrusted(f.name, n)).join(", ")} waiting on it`
|
|
56
|
+
);
|
|
44
57
|
if (f.stack) {
|
|
45
58
|
const card = f.stack.ticket ? ` ${f.stack.ticket}` : "";
|
|
46
59
|
parts.push(
|
|
@@ -20,6 +20,7 @@ import { prFor, storedPR } from "../tasks/session";
|
|
|
20
20
|
import { lastActiveOf } from "../tasks/last-active";
|
|
21
21
|
import * as taskClient from "../tasks/contract/client";
|
|
22
22
|
import { deriveTaskState } from "../tasks";
|
|
23
|
+
import { waitBadges } from "../bus/waits/store";
|
|
23
24
|
import { waitingState } from "./task-state";
|
|
24
25
|
import { commitTime, repoOf } from "./repo";
|
|
25
26
|
import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
|
|
@@ -44,6 +45,11 @@ export interface SessionFacts {
|
|
|
44
45
|
compacting?: boolean;
|
|
45
46
|
// Last sign of life, in ms.
|
|
46
47
|
lastActive: number;
|
|
48
|
+
// Open waits (aos send --await, aos wait): who it waits on, and who waits on it.
|
|
49
|
+
waits?: {
|
|
50
|
+
on: { name: string; kind: string; leftMs: number }[];
|
|
51
|
+
by: string[];
|
|
52
|
+
} | null;
|
|
47
53
|
// Work that should end in a PR: a task, or a session on its own branch.
|
|
48
54
|
branch: string | null;
|
|
49
55
|
// A quick change the orchestrator makes itself (quick.ts): no agent.
|
|
@@ -301,6 +307,7 @@ export async function sessionFacts(
|
|
|
301
307
|
workspaceId: string
|
|
302
308
|
): Promise<SessionFacts[]> {
|
|
303
309
|
await statusDetector.refreshCache();
|
|
310
|
+
const badges = waitBadges();
|
|
304
311
|
return Promise.all(
|
|
305
312
|
workspaceSessions(workspaceId).map(async (s) => {
|
|
306
313
|
const [{ status, activity, needsInput }, task] = await Promise.all([
|
|
@@ -315,6 +322,16 @@ export async function sessionFacts(
|
|
|
315
322
|
status,
|
|
316
323
|
activity,
|
|
317
324
|
needsInput,
|
|
325
|
+
waits: badges.has(s.id)
|
|
326
|
+
? {
|
|
327
|
+
on: badges.get(s.id)!.on.map((w) => ({
|
|
328
|
+
name: w.name,
|
|
329
|
+
kind: w.kind,
|
|
330
|
+
leftMs: Math.max(0, w.deadline - Date.now()),
|
|
331
|
+
})),
|
|
332
|
+
by: badges.get(s.id)!.by.map((w) => w.name),
|
|
333
|
+
}
|
|
334
|
+
: null,
|
|
318
335
|
stalled:
|
|
319
336
|
s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
|
|
320
337
|
compacting:
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other machines on this machine's Connect account, and whether its plan
|
|
3
|
+
* includes linking. Connect only introduces: a listed key is who may ask to
|
|
4
|
+
* pair, never who is trusted (that takes the code typed on the target).
|
|
5
|
+
*
|
|
6
|
+
* Kept in memory, shared through globalThis by the server and the routes.
|
|
7
|
+
* The list is fetched when it's older than a minute and someone needs it,
|
|
8
|
+
* and marked stale when the heartbeat's peers_etag changes; fetches are
|
|
9
|
+
* shared and spaced, so a flood of hellos can't hammer the service.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
import { readConnect } from "../connect/config";
|
|
14
|
+
import {
|
|
15
|
+
listPeers,
|
|
16
|
+
type Beat,
|
|
17
|
+
type Entitlements,
|
|
18
|
+
type PeerList,
|
|
19
|
+
} from "../connect/control-api";
|
|
20
|
+
import { peerHostAllowed } from "./client";
|
|
21
|
+
import { rawPublic } from "./keys";
|
|
22
|
+
|
|
23
|
+
export interface AccountPeer {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
hostname: string;
|
|
27
|
+
/** Its Ed25519 public key, base64url of the raw 32 bytes. */
|
|
28
|
+
signPub: string;
|
|
29
|
+
lastSeenAt: string | null;
|
|
30
|
+
version: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AccountView {
|
|
34
|
+
enrolled: boolean;
|
|
35
|
+
entitlements: Entitlements | null;
|
|
36
|
+
peers: AccountPeer[];
|
|
37
|
+
/** When the list was last fetched, 0 if never. */
|
|
38
|
+
fetchedAt: number;
|
|
39
|
+
error: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const LIST_MAX_AGE_MS = 60_000;
|
|
43
|
+
// Never more often than this, whoever asks.
|
|
44
|
+
const MIN_GAP_MS = 5_000;
|
|
45
|
+
|
|
46
|
+
interface State {
|
|
47
|
+
entitlements: Entitlements | null;
|
|
48
|
+
etag: string | null;
|
|
49
|
+
peers: AccountPeer[];
|
|
50
|
+
fetchedAt: number;
|
|
51
|
+
triedAt: number;
|
|
52
|
+
stale: boolean;
|
|
53
|
+
error: string | null;
|
|
54
|
+
inflight: Promise<void> | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Fetcher = () => Promise<PeerList | null>;
|
|
58
|
+
|
|
59
|
+
const g = globalThis as unknown as {
|
|
60
|
+
__agentosPeerAccount?: State;
|
|
61
|
+
__agentosPeerFetcher?: Fetcher;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const fresh = (): State => ({
|
|
65
|
+
entitlements: null,
|
|
66
|
+
etag: null,
|
|
67
|
+
peers: [],
|
|
68
|
+
fetchedAt: 0,
|
|
69
|
+
triedAt: 0,
|
|
70
|
+
stale: false,
|
|
71
|
+
error: null,
|
|
72
|
+
inflight: null,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const state = () => (g.__agentosPeerAccount ??= fresh());
|
|
76
|
+
|
|
77
|
+
/** The signed call, or null when this machine isn't enrolled. */
|
|
78
|
+
const realFetcher: Fetcher = async () => {
|
|
79
|
+
const read = readConnect();
|
|
80
|
+
if (!read.ok) return null;
|
|
81
|
+
return listPeers(read.files.config, read.files.machineKey);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Tests: a fake Connect service, and a clean slate. */
|
|
85
|
+
export function setAccountFetcher(f: Fetcher | null) {
|
|
86
|
+
g.__agentosPeerFetcher = f ?? undefined;
|
|
87
|
+
g.__agentosPeerAccount = fresh();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Control's SPKI PEM as the raw key the pins use, or null. */
|
|
91
|
+
export function rawFromSpki(pem: string): string | null {
|
|
92
|
+
try {
|
|
93
|
+
const key = crypto.createPublicKey(pem);
|
|
94
|
+
return key.asymmetricKeyType === "ed25519" ? rawPublic(key) : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function adopt(list: PeerList) {
|
|
101
|
+
const s = state();
|
|
102
|
+
const peers: AccountPeer[] = [];
|
|
103
|
+
for (const m of list.peers) {
|
|
104
|
+
const signPub = rawFromSpki(m.publicKey);
|
|
105
|
+
// Only a Connect machine's own name: a lying list can't aim this
|
|
106
|
+
// machine anywhere else.
|
|
107
|
+
if (!signPub || !peerHostAllowed(m.hostname)) continue;
|
|
108
|
+
peers.push({
|
|
109
|
+
id: m.id,
|
|
110
|
+
name: m.name,
|
|
111
|
+
hostname: m.hostname,
|
|
112
|
+
signPub,
|
|
113
|
+
lastSeenAt: m.lastSeenAt,
|
|
114
|
+
version: m.version,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
s.peers = peers;
|
|
118
|
+
if (list.entitlements) s.entitlements = list.entitlements;
|
|
119
|
+
if (list.peersEtag) s.etag = list.peersEtag;
|
|
120
|
+
s.fetchedAt = Date.now();
|
|
121
|
+
s.stale = false;
|
|
122
|
+
s.error = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function refresh(): Promise<void> {
|
|
126
|
+
const s = state();
|
|
127
|
+
if (s.inflight) return s.inflight;
|
|
128
|
+
if (Date.now() - s.triedAt < MIN_GAP_MS) return;
|
|
129
|
+
s.triedAt = Date.now();
|
|
130
|
+
s.inflight = (async () => {
|
|
131
|
+
try {
|
|
132
|
+
const list = await (g.__agentosPeerFetcher ?? realFetcher)();
|
|
133
|
+
if (list) adopt(list);
|
|
134
|
+
else {
|
|
135
|
+
s.peers = [];
|
|
136
|
+
s.error = "This machine isn't connected to AgentOS Connect.";
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
// The last list stays; only its age says it's old.
|
|
140
|
+
s.error = `Can't reach your AgentOS account: ${
|
|
141
|
+
err instanceof Error ? err.message : String(err)
|
|
142
|
+
}`.slice(0, 300);
|
|
143
|
+
} finally {
|
|
144
|
+
s.inflight = null;
|
|
145
|
+
}
|
|
146
|
+
})();
|
|
147
|
+
return s.inflight;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const enrolled = () =>
|
|
151
|
+
g.__agentosPeerFetcher ? true : readConnect().ok === true;
|
|
152
|
+
|
|
153
|
+
/** The account's machines, fetched first if the list is older than maxAgeMs. */
|
|
154
|
+
export async function accountView(
|
|
155
|
+
maxAgeMs = LIST_MAX_AGE_MS
|
|
156
|
+
): Promise<AccountView> {
|
|
157
|
+
if (!enrolled())
|
|
158
|
+
return {
|
|
159
|
+
enrolled: false,
|
|
160
|
+
entitlements: null,
|
|
161
|
+
peers: [],
|
|
162
|
+
fetchedAt: 0,
|
|
163
|
+
error: null,
|
|
164
|
+
};
|
|
165
|
+
const s = state();
|
|
166
|
+
if (s.stale || Date.now() - s.fetchedAt > maxAgeMs) await refresh();
|
|
167
|
+
return {
|
|
168
|
+
enrolled: true,
|
|
169
|
+
entitlements: s.entitlements,
|
|
170
|
+
peers: s.peers,
|
|
171
|
+
fetchedAt: s.fetchedAt,
|
|
172
|
+
error: s.error,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The machine as the account lists it now, or null when it isn't listed. */
|
|
177
|
+
export async function listedPeer(
|
|
178
|
+
machineId: string
|
|
179
|
+
): Promise<AccountPeer | null> {
|
|
180
|
+
const view = await accountView();
|
|
181
|
+
return view.peers.find((p) => p.id === machineId) ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Whether the plan includes linking: null until the service has said. */
|
|
185
|
+
export async function linkingAllowed(): Promise<boolean | null> {
|
|
186
|
+
const { entitlements } = await accountView();
|
|
187
|
+
return entitlements ? entitlements.linkMachines : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What a heartbeat says: the plan now, and whether the list changed. */
|
|
191
|
+
export function noteHeartbeat(beat: Beat) {
|
|
192
|
+
const s = state();
|
|
193
|
+
if (beat.entitlements) s.entitlements = beat.entitlements;
|
|
194
|
+
if (beat.peersEtag && beat.peersEtag !== s.etag) {
|
|
195
|
+
s.etag = beat.peersEtag;
|
|
196
|
+
s.stale = true;
|
|
197
|
+
}
|
|
198
|
+
}
|