@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/bus/index.ts
CHANGED
|
@@ -5,16 +5,20 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { db, type Session } from "../db";
|
|
8
|
-
import { getProject } from "../projects";
|
|
9
8
|
import { statusDetector } from "../status-detector";
|
|
10
9
|
import { chatState, sendChatConfirmed } from "../chat/runner";
|
|
11
10
|
import { chatActivityLine } from "../chat/activity";
|
|
12
11
|
import { getWorkspace } from "../workspaces";
|
|
13
12
|
import type { ChatOrigin, PeerMessage } from "../chat/events";
|
|
14
13
|
import { sessionRowInfo } from "../session-meta";
|
|
15
|
-
import { previousNames } from "../session-names";
|
|
16
14
|
import { deliverToPane, type Delivery } from "./delivery";
|
|
17
|
-
import {
|
|
15
|
+
import { wasNames } from "./resolve";
|
|
16
|
+
import {
|
|
17
|
+
candidates,
|
|
18
|
+
liveSessions,
|
|
19
|
+
sessionById,
|
|
20
|
+
tryResolveTarget,
|
|
21
|
+
} from "./targets";
|
|
18
22
|
import { tmuxPane } from "./tmux-pane";
|
|
19
23
|
import { pauseHoldOf, pausedNote } from "../tasks/pause-hold";
|
|
20
24
|
import { isHeldRemote, relayToHeldTask } from "../tasks/contract/held-calls";
|
|
@@ -25,9 +29,22 @@ import {
|
|
|
25
29
|
type QueuedTarget,
|
|
26
30
|
} from "../tasks/queue-held";
|
|
27
31
|
import { heldOrchestrator, heldTarget } from "./held-reply";
|
|
32
|
+
import {
|
|
33
|
+
cantWaitOn,
|
|
34
|
+
claimClose,
|
|
35
|
+
excerpt,
|
|
36
|
+
openWait,
|
|
37
|
+
replyWaitsFor,
|
|
38
|
+
waitBadges,
|
|
39
|
+
} from "./waits/store";
|
|
28
40
|
import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
|
|
29
41
|
|
|
30
42
|
export * from "./format";
|
|
43
|
+
export {
|
|
44
|
+
resolveHeldOrchestrator,
|
|
45
|
+
resolveSession,
|
|
46
|
+
resolveTarget,
|
|
47
|
+
} from "./targets";
|
|
31
48
|
export type { Delivery } from "./delivery";
|
|
32
49
|
|
|
33
50
|
export interface Peer {
|
|
@@ -44,35 +61,11 @@ export interface Peer {
|
|
|
44
61
|
orchestratorOf: string | null;
|
|
45
62
|
// The asking session's own workspace orchestrator.
|
|
46
63
|
yours?: boolean;
|
|
64
|
+
// Names of the sessions it waits on, and of those waiting on it.
|
|
65
|
+
waitingOn: string[];
|
|
66
|
+
waitedBy: string[];
|
|
47
67
|
}
|
|
48
68
|
|
|
49
|
-
function liveSessions(): Session[] {
|
|
50
|
-
return db
|
|
51
|
-
.prepare(
|
|
52
|
-
`SELECT * FROM session_rows WHERE (task_status IS NULL OR task_status = 'running')
|
|
53
|
-
AND archived_at IS NULL
|
|
54
|
-
ORDER BY updated_at DESC`
|
|
55
|
-
)
|
|
56
|
-
.all() as Session[];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function candidates(sessions: Session[]): Candidate[] {
|
|
60
|
-
const old = previousNames();
|
|
61
|
-
return sessions.map((s) => ({
|
|
62
|
-
id: s.id,
|
|
63
|
-
name: s.name,
|
|
64
|
-
projectName: s.project_id ? (getProject(s.project_id)?.name ?? null) : null,
|
|
65
|
-
tmuxName: s.tmux_name,
|
|
66
|
-
previousNames: old.get(s.id) ?? [],
|
|
67
|
-
}));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const sessionById = (id: string | null | undefined) =>
|
|
71
|
-
id
|
|
72
|
-
? (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
|
|
73
|
-
Session | undefined)
|
|
74
|
-
: undefined;
|
|
75
|
-
|
|
76
69
|
// A chat has no pane: its worker is the state, and it can always take a
|
|
77
70
|
// message (queued as its next prompt), so it is never "not running".
|
|
78
71
|
async function peerState(
|
|
@@ -105,6 +98,7 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
|
105
98
|
const named = candidates(sessions);
|
|
106
99
|
const caller = sessionById(callerId);
|
|
107
100
|
const orchestrator = heldOrchestrator(caller);
|
|
101
|
+
const badges = waitBadges();
|
|
108
102
|
const listed = await Promise.all(
|
|
109
103
|
sessions.map(async (s, i) => {
|
|
110
104
|
const info = await peerState(s);
|
|
@@ -118,6 +112,8 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
|
118
112
|
running: info.running,
|
|
119
113
|
doing: info.subtitle,
|
|
120
114
|
orchestratorOf: orchestratorOf(s),
|
|
115
|
+
waitingOn: badges.get(s.id)?.on.map((w) => w.name) ?? [],
|
|
116
|
+
waitedBy: badges.get(s.id)?.by.map((w) => w.name) ?? [],
|
|
121
117
|
...(caller?.workspace_id &&
|
|
122
118
|
s.role === "orchestrator" &&
|
|
123
119
|
s.workspace_id === caller.workspace_id && { yours: true }),
|
|
@@ -137,84 +133,15 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
|
137
133
|
running: true,
|
|
138
134
|
doing: `your orchestrator, on ${orchestrator.heldFrom}`,
|
|
139
135
|
orchestratorOf: null,
|
|
136
|
+
waitingOn: [],
|
|
137
|
+
waitedBy: [],
|
|
140
138
|
yours: true,
|
|
141
139
|
},
|
|
142
140
|
];
|
|
143
141
|
}
|
|
144
142
|
|
|
145
|
-
// The orchestrator a held caller's ref names, if it names it.
|
|
146
|
-
export function resolveHeldOrchestrator(callerId: string, ref: string) {
|
|
147
|
-
return heldTarget(sessionById(callerId), ref);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// A live session by name, old name, project/name, id or id prefix. The note
|
|
151
|
-
// says when the name meant something other than its current owner.
|
|
152
|
-
export function resolveTarget(
|
|
153
|
-
ref: string,
|
|
154
|
-
callerId?: string | null
|
|
155
|
-
): {
|
|
156
|
-
session: Session;
|
|
157
|
-
note?: string;
|
|
158
|
-
} {
|
|
159
|
-
const found = tryResolveTarget(ref, callerId);
|
|
160
|
-
if (!found.ok) throw new Error(found.error);
|
|
161
|
-
return found;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// "orchestrator" / "my orchestrator": the caller's own workspace orchestrator.
|
|
165
|
-
function ownOrchestrator(
|
|
166
|
-
ref: string,
|
|
167
|
-
sessions: Session[],
|
|
168
|
-
callerId?: string | null
|
|
169
|
-
): Session | undefined {
|
|
170
|
-
if (!/^(my )?orchestrator$/i.test(ref.trim())) return undefined;
|
|
171
|
-
const workspace = sessionById(callerId)?.workspace_id;
|
|
172
|
-
if (!workspace) return undefined;
|
|
173
|
-
return sessions.find(
|
|
174
|
-
(s) => s.role === "orchestrator" && s.workspace_id === workspace
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function tryResolveTarget(
|
|
179
|
-
ref: string,
|
|
180
|
-
callerId?: string | null
|
|
181
|
-
):
|
|
182
|
-
| { ok: true; session: Session; note?: string }
|
|
183
|
-
| { ok: false; none: boolean; error: string } {
|
|
184
|
-
const sessions = liveSessions();
|
|
185
|
-
const own = ownOrchestrator(ref, sessions, callerId);
|
|
186
|
-
if (own) return { ok: true, session: own };
|
|
187
|
-
const r = resolveRef(ref, candidates(sessions));
|
|
188
|
-
if (!r.ok) {
|
|
189
|
-
const orchestrators = /^(my )?orchestrator$/i.test(ref.trim())
|
|
190
|
-
? sessions.filter((s) => s.role === "orchestrator")
|
|
191
|
-
: [];
|
|
192
|
-
if (orchestrators.length)
|
|
193
|
-
return {
|
|
194
|
-
ok: false,
|
|
195
|
-
none: true,
|
|
196
|
-
error: `You are in no workspace, so "${ref}" is ambiguous. Orchestrators: ${orchestrators
|
|
197
|
-
.map((s) => `${s.name} (id ${s.id.slice(0, 8)})`)
|
|
198
|
-
.join(", ")}`,
|
|
199
|
-
};
|
|
200
|
-
return {
|
|
201
|
-
ok: false,
|
|
202
|
-
none: r.reason === "none",
|
|
203
|
-
error: r.reason === "none" ? `${r.error}. Run: aos peers` : r.error,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
return {
|
|
207
|
-
ok: true,
|
|
208
|
-
session: sessions.find((s) => s.id === r.id)!,
|
|
209
|
-
note: r.note,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export function resolveSession(ref: string): Session {
|
|
214
|
-
return resolveTarget(ref).session;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
143
|
type Row = {
|
|
144
|
+
reply_to: number | null;
|
|
218
145
|
id: number;
|
|
219
146
|
from_id: string | null;
|
|
220
147
|
from_name: string;
|
|
@@ -234,11 +161,12 @@ const toView = (r: Row): BusMessageView => ({
|
|
|
234
161
|
body: r.body,
|
|
235
162
|
createdAt: r.created_at,
|
|
236
163
|
readAt: r.read_at,
|
|
164
|
+
replyTo: r.reply_to,
|
|
237
165
|
});
|
|
238
166
|
|
|
239
167
|
// Messages are kept by id; names shown are the sessions' names now, the
|
|
240
168
|
// stored ones only for a session that's gone.
|
|
241
|
-
const SELECT = `SELECT m.id, m.from_id, m.to_id, m.body, m.created_at, m.read_at,
|
|
169
|
+
const SELECT = `SELECT m.id, m.from_id, m.to_id, m.body, m.created_at, m.read_at, m.reply_to,
|
|
242
170
|
CASE WHEN m.from_id IS NULL THEN m.from_name
|
|
243
171
|
ELSE COALESCE(f.name, m.from_name) END AS from_name,
|
|
244
172
|
COALESCE(t.name, m.to_name) AS to_name
|
|
@@ -338,10 +266,17 @@ export async function sendMessage(opts: {
|
|
|
338
266
|
// A message pulled from a linked machine, by its key there: one already
|
|
339
267
|
// stored is not stored or delivered again.
|
|
340
268
|
relayKey?: string;
|
|
269
|
+
// Answers this message (one the recipient sent the sender). Without it a
|
|
270
|
+
// message answers the recipient's oldest open wait on the sender.
|
|
271
|
+
replyTo?: number;
|
|
272
|
+
// The sender waits for the recipient's reply; ms until it gives up.
|
|
273
|
+
awaitMs?: number;
|
|
341
274
|
}): Promise<{
|
|
342
275
|
message: BusMessageView;
|
|
343
276
|
delivery: Delivery;
|
|
344
277
|
note?: string;
|
|
278
|
+
// The wait recorded for --await, or why none was.
|
|
279
|
+
wait?: { id: number; deadline: number } | { notWaiting: string };
|
|
345
280
|
// The task it was held for, when that task is still in the start queue.
|
|
346
281
|
heldForQueued?: boolean;
|
|
347
282
|
}> {
|
|
@@ -380,10 +315,13 @@ export async function sendMessage(opts: {
|
|
|
380
315
|
};
|
|
381
316
|
}
|
|
382
317
|
|
|
318
|
+
const reply = from ? replyTarget(from, to, opts.replyTo) : null;
|
|
319
|
+
if (opts.awaitMs && !from) throw new Error("Only a session can wait");
|
|
320
|
+
|
|
383
321
|
const fromName = from?.name ?? opts.fromLabel ?? HUMAN;
|
|
384
322
|
const { lastInsertRowid } = db
|
|
385
323
|
.prepare(
|
|
386
|
-
`INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_key) VALUES (?, ?, ?, ?, ?, ?)`
|
|
324
|
+
`INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_key, reply_to) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
387
325
|
)
|
|
388
326
|
.run(
|
|
389
327
|
from?.id ?? null,
|
|
@@ -391,9 +329,13 @@ export async function sendMessage(opts: {
|
|
|
391
329
|
to.id,
|
|
392
330
|
to.name,
|
|
393
331
|
body,
|
|
394
|
-
opts.relayKey ?? null
|
|
332
|
+
opts.relayKey ?? null,
|
|
333
|
+
reply?.id ?? null
|
|
395
334
|
);
|
|
396
335
|
const id = Number(lastInsertRowid);
|
|
336
|
+
// The reply is stored: whatever it answers is settled, whether or not
|
|
337
|
+
// the wake line gets through.
|
|
338
|
+
if (from) settleReplyWaits(to.id, from.id, id);
|
|
397
339
|
|
|
398
340
|
await statusDetector.refreshCache();
|
|
399
341
|
const delivery = await deliver(
|
|
@@ -402,7 +344,12 @@ export async function sendMessage(opts: {
|
|
|
402
344
|
wakeLine(
|
|
403
345
|
{ fromName, fromId: from?.id ?? null, body },
|
|
404
346
|
// The linked machine's inbox never held a long body.
|
|
405
|
-
{
|
|
347
|
+
{
|
|
348
|
+
inline: isHeldRemote(to),
|
|
349
|
+
replyTo: reply
|
|
350
|
+
? { id: reply.id, excerpt: excerpt(reply.body, 60) }
|
|
351
|
+
: undefined,
|
|
352
|
+
}
|
|
406
353
|
),
|
|
407
354
|
{
|
|
408
355
|
name: fromName,
|
|
@@ -427,13 +374,73 @@ export async function sendMessage(opts: {
|
|
|
427
374
|
).run(id);
|
|
428
375
|
const message = toView(db.prepare(`${SELECT} WHERE m.id = ?`).get(id) as Row);
|
|
429
376
|
const heldNote = hold ? pausedNote(to.name, hold) : null;
|
|
377
|
+
const wait =
|
|
378
|
+
opts.awaitMs && from
|
|
379
|
+
? keepWait(from, to, id, body, delivery, opts.awaitMs)
|
|
380
|
+
: undefined;
|
|
430
381
|
return {
|
|
431
382
|
message,
|
|
432
383
|
delivery,
|
|
433
384
|
note: [note, heldNote].filter(Boolean).join("; ") || undefined,
|
|
385
|
+
wait,
|
|
434
386
|
};
|
|
435
387
|
}
|
|
436
388
|
|
|
389
|
+
// The message `replyTo` names, or the oldest request `from` was waited on
|
|
390
|
+
// for by `to`: the one this message answers.
|
|
391
|
+
function replyTarget(
|
|
392
|
+
from: Session,
|
|
393
|
+
to: Session,
|
|
394
|
+
replyTo?: number
|
|
395
|
+
): { id: number; body: string } | null {
|
|
396
|
+
if (replyTo !== undefined) {
|
|
397
|
+
const m = db
|
|
398
|
+
.prepare(
|
|
399
|
+
`SELECT id, body FROM bus_messages WHERE id = ? AND from_id = ? AND to_id = ?`
|
|
400
|
+
)
|
|
401
|
+
.get(replyTo, to.id, from.id) as { id: number; body: string } | undefined;
|
|
402
|
+
if (!m)
|
|
403
|
+
throw new Error(`Message #${replyTo} wasn't sent to you by ${to.name}`);
|
|
404
|
+
return m;
|
|
405
|
+
}
|
|
406
|
+
const wait = replyWaitsFor(to.id, from.id).find((w) => w.message_id);
|
|
407
|
+
if (!wait) return null;
|
|
408
|
+
return (
|
|
409
|
+
(db
|
|
410
|
+
.prepare(`SELECT id, body FROM bus_messages WHERE id = ?`)
|
|
411
|
+
.get(wait.message_id) as { id: number; body: string } | undefined) ?? null
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// `from` answered `waiter`: every reply wait it had on `from` is settled.
|
|
416
|
+
function settleReplyWaits(waiter: string, from: string, replyId: number) {
|
|
417
|
+
for (const w of replyWaitsFor(waiter, from))
|
|
418
|
+
claimClose(w.id, "replied", replyId);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function keepWait(
|
|
422
|
+
from: Session,
|
|
423
|
+
to: Session,
|
|
424
|
+
messageId: number,
|
|
425
|
+
body: string,
|
|
426
|
+
delivery: Delivery,
|
|
427
|
+
ms: number
|
|
428
|
+
): { id: number; deadline: number } | { notWaiting: string } {
|
|
429
|
+
if (delivery.state === "failed") return { notWaiting: "it wasn't delivered" };
|
|
430
|
+
const why = cantWaitOn(to);
|
|
431
|
+
if (why) return { notWaiting: why };
|
|
432
|
+
const deadline = Date.now() + ms;
|
|
433
|
+
const id = openWait({
|
|
434
|
+
waiterId: from.id,
|
|
435
|
+
targetId: to.id,
|
|
436
|
+
kind: "reply",
|
|
437
|
+
messageId,
|
|
438
|
+
request: excerpt(body),
|
|
439
|
+
deadline,
|
|
440
|
+
});
|
|
441
|
+
return { id, deadline };
|
|
442
|
+
}
|
|
443
|
+
|
|
437
444
|
function storeForHolder(
|
|
438
445
|
from: Session,
|
|
439
446
|
to: { id: string; name: string; heldFrom: string },
|
package/lib/bus/resolve.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface Candidate {
|
|
|
11
11
|
tmuxName: string;
|
|
12
12
|
// Newest first.
|
|
13
13
|
previousNames: string[];
|
|
14
|
+
workspaceName?: string | null;
|
|
15
|
+
orchestrator?: boolean;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export type Resolution =
|
|
@@ -20,7 +22,13 @@ export type Resolution =
|
|
|
20
22
|
export const MIN_ID_PREFIX = 4;
|
|
21
23
|
|
|
22
24
|
const label = (c: Candidate) =>
|
|
23
|
-
`${
|
|
25
|
+
`${
|
|
26
|
+
c.orchestrator && c.workspaceName
|
|
27
|
+
? `${c.workspaceName}/`
|
|
28
|
+
: c.projectName
|
|
29
|
+
? `${c.projectName}/`
|
|
30
|
+
: ""
|
|
31
|
+
}${c.name} (id ${c.id.slice(0, 8)})`;
|
|
24
32
|
|
|
25
33
|
function ambiguous(ref: string, matches: Candidate[]): Resolution {
|
|
26
34
|
return {
|
|
@@ -32,7 +40,13 @@ function ambiguous(ref: string, matches: Candidate[]): Resolution {
|
|
|
32
40
|
|
|
33
41
|
const matchesOld = (r: string, c: Candidate) =>
|
|
34
42
|
c.previousNames.some((old) =>
|
|
35
|
-
matchesRef(r, {
|
|
43
|
+
matchesRef(r, {
|
|
44
|
+
...c,
|
|
45
|
+
id: "",
|
|
46
|
+
tmuxName: "",
|
|
47
|
+
name: old,
|
|
48
|
+
orchestrator: false,
|
|
49
|
+
})
|
|
36
50
|
);
|
|
37
51
|
|
|
38
52
|
export function resolveRef(ref: string, candidates: Candidate[]): Resolution {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which live session a name someone typed reaches, across workspaces.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { db, type Session } from "../db";
|
|
6
|
+
import { getProject } from "../projects";
|
|
7
|
+
import { getWorkspace } from "../workspaces";
|
|
8
|
+
import { previousNames } from "../session-names";
|
|
9
|
+
import { sessionWorkspace } from "../schedules/store";
|
|
10
|
+
import { heldTarget } from "./held-reply";
|
|
11
|
+
import { resolveRef, type Candidate } from "./resolve";
|
|
12
|
+
|
|
13
|
+
export function liveSessions(): Session[] {
|
|
14
|
+
return db
|
|
15
|
+
.prepare(
|
|
16
|
+
`SELECT * FROM session_rows WHERE (task_status IS NULL OR task_status = 'running')
|
|
17
|
+
AND archived_at IS NULL
|
|
18
|
+
ORDER BY updated_at DESC`
|
|
19
|
+
)
|
|
20
|
+
.all() as Session[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function candidates(sessions: Session[]): Candidate[] {
|
|
24
|
+
const old = previousNames();
|
|
25
|
+
const workspaces = new Map<string | null, string | null>();
|
|
26
|
+
const workspaceName = (s: Session) => {
|
|
27
|
+
const id = sessionWorkspace(s);
|
|
28
|
+
if (!workspaces.has(id))
|
|
29
|
+
workspaces.set(id, id ? (getWorkspace(id)?.name ?? null) : null);
|
|
30
|
+
return workspaces.get(id) ?? null;
|
|
31
|
+
};
|
|
32
|
+
return sessions.map((s) => ({
|
|
33
|
+
id: s.id,
|
|
34
|
+
name: s.name,
|
|
35
|
+
projectName: s.project_id ? (getProject(s.project_id)?.name ?? null) : null,
|
|
36
|
+
tmuxName: s.tmux_name,
|
|
37
|
+
previousNames: old.get(s.id) ?? [],
|
|
38
|
+
workspaceName: workspaceName(s),
|
|
39
|
+
orchestrator: s.role === "orchestrator",
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const sessionById = (id: string | null | undefined) =>
|
|
44
|
+
id
|
|
45
|
+
? (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
|
|
46
|
+
Session | undefined)
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
// The orchestrator a held caller's ref names, if it names it.
|
|
50
|
+
export function resolveHeldOrchestrator(callerId: string, ref: string) {
|
|
51
|
+
return heldTarget(sessionById(callerId), ref);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A live session by name, old name, project/name, id or id prefix. The note
|
|
55
|
+
// says when the name meant something other than its current owner.
|
|
56
|
+
export function resolveTarget(
|
|
57
|
+
ref: string,
|
|
58
|
+
callerId?: string | null
|
|
59
|
+
): {
|
|
60
|
+
session: Session;
|
|
61
|
+
note?: string;
|
|
62
|
+
} {
|
|
63
|
+
const found = tryResolveTarget(ref, callerId);
|
|
64
|
+
if (!found.ok) throw new Error(found.error);
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// "orchestrator" / "my orchestrator": the caller's own workspace orchestrator.
|
|
69
|
+
function ownOrchestrator(
|
|
70
|
+
ref: string,
|
|
71
|
+
sessions: Session[],
|
|
72
|
+
callerId?: string | null
|
|
73
|
+
): Session | undefined {
|
|
74
|
+
if (!/^(my )?orchestrator$/i.test(ref.trim())) return undefined;
|
|
75
|
+
const workspace = sessionById(callerId)?.workspace_id;
|
|
76
|
+
if (!workspace) return undefined;
|
|
77
|
+
return sessions.find(
|
|
78
|
+
(s) => s.role === "orchestrator" && s.workspace_id === workspace
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function tryResolveTarget(
|
|
83
|
+
ref: string,
|
|
84
|
+
callerId?: string | null
|
|
85
|
+
):
|
|
86
|
+
| { ok: true; session: Session; note?: string }
|
|
87
|
+
| { ok: false; none: boolean; error: string } {
|
|
88
|
+
const sessions = liveSessions();
|
|
89
|
+
const own = ownOrchestrator(ref, sessions, callerId);
|
|
90
|
+
if (own) return { ok: true, session: own };
|
|
91
|
+
const r = resolveRef(ref, candidates(sessions));
|
|
92
|
+
if (!r.ok) {
|
|
93
|
+
const orchestrators = /^(my )?orchestrator$/i.test(ref.trim())
|
|
94
|
+
? sessions.filter((s) => s.role === "orchestrator")
|
|
95
|
+
: [];
|
|
96
|
+
if (orchestrators.length)
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
none: true,
|
|
100
|
+
error: `You are in no workspace, so "${ref}" is ambiguous. Use <workspace>/orchestrator: ${orchestrators
|
|
101
|
+
.map(
|
|
102
|
+
(s) =>
|
|
103
|
+
`${getWorkspace(s.workspace_id ?? "")?.name ?? "?"}/orchestrator (${s.name}, id ${s.id.slice(0, 8)})`
|
|
104
|
+
)
|
|
105
|
+
.join(", ")}`,
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
none: r.reason === "none",
|
|
110
|
+
error: r.reason === "none" ? `${r.error}. Run: aos peers` : r.error,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
session: sessions.find((s) => s.id === r.id)!,
|
|
116
|
+
note: r.note,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveSession(ref: string): Session {
|
|
121
|
+
return resolveTarget(ref).session;
|
|
122
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Lets the places where a session ends ask for a look at open waits now,
|
|
2
|
+
// without importing the bus. The watcher registers itself here.
|
|
3
|
+
|
|
4
|
+
let kick: (() => void) | null = null;
|
|
5
|
+
|
|
6
|
+
export function onKick(fn: () => void): void {
|
|
7
|
+
kick = fn;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function kickWaits(): void {
|
|
11
|
+
kick?.();
|
|
12
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Every text an agent reads about a wait. Each starts the way a bus message
|
|
2
|
+
// does, so a line left unsent in a terminal's input is recognised as ours.
|
|
3
|
+
|
|
4
|
+
import { shortId } from "../format";
|
|
5
|
+
|
|
6
|
+
const from = "[AgentOS message from AgentOS]";
|
|
7
|
+
const who = (name: string, id: string) => `"${name}" (${shortId(id)})`;
|
|
8
|
+
|
|
9
|
+
export const minutes = (ms: number) =>
|
|
10
|
+
ms >= 3_600_000
|
|
11
|
+
? `${Math.round(ms / 360_000) / 10}h`
|
|
12
|
+
: `${Math.max(1, Math.round(ms / 60_000))}m`;
|
|
13
|
+
|
|
14
|
+
export function nudgeLine(
|
|
15
|
+
waiter: { id: string; name: string },
|
|
16
|
+
request: string | null,
|
|
17
|
+
messageId: number | null
|
|
18
|
+
): string {
|
|
19
|
+
const reply = messageId ? ` --reply ${messageId}` : "";
|
|
20
|
+
return `${from} ${who(waiter.name, waiter.id)} is waiting on your reply${request ? ` to: "${request}"` : ""}. Reply with: aos send ${shortId(waiter.id)}${reply} "<message>"`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function timeoutLine(
|
|
24
|
+
target: { id: string; name: string },
|
|
25
|
+
kind: "reply" | "idle" | "merged",
|
|
26
|
+
waitedMs: number,
|
|
27
|
+
request: string | null,
|
|
28
|
+
targetIs: string
|
|
29
|
+
): string {
|
|
30
|
+
const what =
|
|
31
|
+
kind === "reply"
|
|
32
|
+
? `No reply from ${who(target.name, target.id)}${request ? ` to: "${request}"` : ""}`
|
|
33
|
+
: kind === "idle"
|
|
34
|
+
? `${who(target.name, target.id)} didn't go idle`
|
|
35
|
+
: `${who(target.name, target.id)}'s PR didn't merge`;
|
|
36
|
+
return `${from} ${what} within ${minutes(waitedMs)}. The wait is closed; it is ${targetIs}. See what it did: aos read ${shortId(target.id)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const satisfiedLine = (
|
|
40
|
+
target: { id: string; name: string },
|
|
41
|
+
kind: "idle" | "merged",
|
|
42
|
+
pr?: number | null
|
|
43
|
+
): string =>
|
|
44
|
+
`${from} ${
|
|
45
|
+
kind === "idle"
|
|
46
|
+
? `${who(target.name, target.id)} is idle now`
|
|
47
|
+
: `${who(target.name, target.id)}'s PR${pr ? ` #${pr}` : ""} merged`
|
|
48
|
+
} (you waited for that). See what it did: aos read ${shortId(target.id)}`;
|
|
49
|
+
|
|
50
|
+
export const endedLine = (
|
|
51
|
+
target: { id: string; name: string },
|
|
52
|
+
how: string,
|
|
53
|
+
request: string | null
|
|
54
|
+
): string =>
|
|
55
|
+
`${from} ${who(target.name, target.id)} ended (${how}) with your wait still open${request ? ` on: "${request}"` : ""}. It won't answer.`;
|
|
56
|
+
|
|
57
|
+
export const closedPrLine = (
|
|
58
|
+
target: { id: string; name: string },
|
|
59
|
+
pr: number | null
|
|
60
|
+
): string =>
|
|
61
|
+
`${from} ${who(target.name, target.id)}'s PR${pr ? ` #${pr}` : ""} was closed without merging. The wait is closed.`;
|
|
62
|
+
|
|
63
|
+
export const orchestratorTimeoutLine = (
|
|
64
|
+
waiter: string,
|
|
65
|
+
target: string,
|
|
66
|
+
waitedMs: number,
|
|
67
|
+
request: string | null
|
|
68
|
+
): string =>
|
|
69
|
+
`session ${waiter} waited ${minutes(waitedMs)} on ${target} with no ${request ? `reply to "${request}"` : "result"}; it has been told`;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Registering a wait: `aos send --await` (in sendMessage) and `aos wait`.
|
|
2
|
+
|
|
3
|
+
import { statusDetector } from "../../status-detector";
|
|
4
|
+
import { statusOf } from "../../orchestrator/facts";
|
|
5
|
+
import { duration } from "../../schedules/when";
|
|
6
|
+
import { sessionById, tryResolveTarget } from "../targets";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_TIMEOUT_MS,
|
|
9
|
+
MAX_TIMEOUT_MS,
|
|
10
|
+
MIN_TIMEOUT_MS,
|
|
11
|
+
cantWaitOn,
|
|
12
|
+
openWait,
|
|
13
|
+
type WaitKind,
|
|
14
|
+
} from "./store";
|
|
15
|
+
|
|
16
|
+
// "30m", "2h", "1h30m"; empty is the default.
|
|
17
|
+
export function parseTimeout(text: string | null | undefined): number {
|
|
18
|
+
const t = String(text ?? "").trim();
|
|
19
|
+
if (!t) return DEFAULT_TIMEOUT_MS;
|
|
20
|
+
const ms = duration(t.toLowerCase().replace(/([a-z])(?=\d)/g, "$1 "));
|
|
21
|
+
if (ms === null)
|
|
22
|
+
throw new Error(`Can't read the timeout "${t}": try 30m, 2h or 1h30m`);
|
|
23
|
+
if (ms < MIN_TIMEOUT_MS || ms > MAX_TIMEOUT_MS)
|
|
24
|
+
throw new Error("The timeout is between 1m and 24h");
|
|
25
|
+
return ms;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const UNTILS = ["idle", "merged", "reply"] as const;
|
|
29
|
+
export type Until = (typeof UNTILS)[number];
|
|
30
|
+
|
|
31
|
+
export async function registerWait(opts: {
|
|
32
|
+
fromId: string;
|
|
33
|
+
to: string;
|
|
34
|
+
until: string;
|
|
35
|
+
timeout?: string | null;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
id?: number;
|
|
38
|
+
deadline?: number;
|
|
39
|
+
already?: string;
|
|
40
|
+
target: string;
|
|
41
|
+
}> {
|
|
42
|
+
if (!(UNTILS as readonly string[]).includes(opts.until))
|
|
43
|
+
throw new Error("--until is idle, merged or reply");
|
|
44
|
+
const kind: WaitKind = opts.until as Until;
|
|
45
|
+
const timeoutMs = parseTimeout(opts.timeout);
|
|
46
|
+
const from = sessionById(opts.fromId);
|
|
47
|
+
if (!from) throw new Error("Unknown sender session");
|
|
48
|
+
const found = tryResolveTarget(opts.to, opts.fromId);
|
|
49
|
+
if (!found.ok) throw new Error(found.error);
|
|
50
|
+
const target = found.session;
|
|
51
|
+
if (target.id === from.id) throw new Error("A session can't wait on itself");
|
|
52
|
+
const why = cantWaitOn(target);
|
|
53
|
+
if (why) throw new Error(`Can't wait on ${target.name}: ${why}`);
|
|
54
|
+
|
|
55
|
+
if (kind === "merged") {
|
|
56
|
+
if (target.pr_status === "merged" || target.task_status === "merged")
|
|
57
|
+
return {
|
|
58
|
+
already: `${target.name}'s PR is already merged`,
|
|
59
|
+
target: target.name,
|
|
60
|
+
};
|
|
61
|
+
if (target.pr_status === "closed")
|
|
62
|
+
throw new Error(`${target.name}'s PR was closed without merging`);
|
|
63
|
+
if (!target.task_status && !target.pr_number && !target.branch_name)
|
|
64
|
+
throw new Error(
|
|
65
|
+
`${target.name} isn't a task and has no PR, so there is nothing to merge`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (kind === "idle") {
|
|
69
|
+
await statusDetector.refreshCache();
|
|
70
|
+
if ((await statusOf(target)).status === "idle")
|
|
71
|
+
return { already: `${target.name} is idle now`, target: target.name };
|
|
72
|
+
}
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const deadline = now + timeoutMs;
|
|
75
|
+
const id = openWait({
|
|
76
|
+
waiterId: from.id,
|
|
77
|
+
targetId: target.id,
|
|
78
|
+
kind,
|
|
79
|
+
deadline,
|
|
80
|
+
now,
|
|
81
|
+
});
|
|
82
|
+
return { id, deadline, target: target.name };
|
|
83
|
+
}
|