@saadnvd1/agent-os 0.3.778 → 0.3.780
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/live-shares/[id]/route.ts +21 -3
- package/app/api/live-shares/route.test.ts +36 -0
- package/app/api/live-shares/route.ts +14 -3
- package/components/Chat/Approval.tsx +13 -2
- package/components/Chat/EventRow.tsx +5 -0
- package/components/Chat/Queue.tsx +27 -15
- package/components/Live/DriveBanner.tsx +42 -0
- package/components/Live/InviteDialog.tsx +44 -13
- package/components/Live/LiveChip.tsx +34 -13
- package/components/Live/LivePanel.tsx +26 -9
- package/components/Live/LobbyCard.tsx +18 -21
- package/components/ui/segmented.tsx +2 -1
- package/data/live/queries.ts +25 -5
- package/lib/chat/driver.ts +4 -0
- package/lib/chat/drivers/claude-approvals.ts +93 -25
- package/lib/chat/drivers/claude.ts +2 -1
- package/lib/chat/events.ts +21 -1
- package/lib/chat/queued.ts +57 -10
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +65 -2
- package/lib/chat/socket.ts +12 -9
- package/lib/chat/worker/gate-testing.ts +131 -0
- package/lib/chat/worker/guest-gate.ts +511 -0
- package/lib/chat/worker/host.ts +50 -6
- package/lib/chat/worker/main.ts +16 -2
- package/lib/chat/worker/protocol.ts +11 -0
- package/lib/connect/control-api.ts +6 -2
- package/lib/db/migrations/20261030T1200_live_share_level.ts +19 -0
- package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
- package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
- package/lib/live/clean.ts +6 -0
- package/lib/live/drive.ts +398 -0
- package/lib/live/frames.ts +9 -2
- package/lib/live/inbox.ts +47 -1
- package/lib/live/levels.ts +74 -0
- package/lib/live/publisher.ts +10 -0
- package/lib/live/runtime.ts +20 -0
- package/lib/live/service.ts +133 -10
- package/lib/live/store.ts +54 -3
- package/lib/live/testing.ts +281 -0
- package/lib/live/types.ts +52 -0
- package/lib/share/connect.ts +1 -0
- package/package.json +1 -1
package/lib/live/service.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Live shares from the host's side: mint, run, decide on guests, end.
|
|
3
3
|
* Nothing a guest sends reaches an agent except through sendSuggestion,
|
|
4
|
-
* which the host taps
|
|
4
|
+
* which the host taps, and a Drive prompt from the guest the host gave the
|
|
5
|
+
* wheel (./drive), whose turn the worker's guest gate holds to the host's taps.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { loadConnect } from "../connect/config";
|
|
@@ -11,12 +12,29 @@ import { shareContext } from "../share/context";
|
|
|
11
12
|
import { redactForShare } from "../share/redact";
|
|
12
13
|
import { ShareError, createLiveRemote, revokeRemote } from "../share/connect";
|
|
13
14
|
import type { LiveExpiry } from "../connect/control-api";
|
|
14
|
-
import
|
|
15
|
-
|
|
15
|
+
import {
|
|
16
|
+
LEVELS,
|
|
17
|
+
type LiveInvite,
|
|
18
|
+
type LiveLevel,
|
|
19
|
+
type LiveView,
|
|
20
|
+
} from "./types";
|
|
21
|
+
import { driveView, guestActions, levelOptions } from "./levels";
|
|
22
|
+
import {
|
|
23
|
+
driveRefusal,
|
|
24
|
+
newDrive,
|
|
25
|
+
onDriveMessage,
|
|
26
|
+
releaseDrive,
|
|
27
|
+
setWheel,
|
|
28
|
+
type DriveFx,
|
|
29
|
+
} from "./drive";
|
|
30
|
+
import type { Session } from "../db";
|
|
31
|
+
import { listItems } from "../chat/store";
|
|
32
|
+
import { visible } from "../share/snapshot";
|
|
33
|
+
import { cleanBody, noMentions } from "./clean";
|
|
16
34
|
import { itemMapper } from "./frames";
|
|
17
35
|
import { handleFrame } from "./inbox";
|
|
18
36
|
import { onMessage, seed, flush } from "./publisher";
|
|
19
|
-
import { Ring } from "./ring";
|
|
37
|
+
import { RING_ITEMS, Ring } from "./ring";
|
|
20
38
|
import { LOBBY_MS, live, type Guest, type Runtime } from "./runtime";
|
|
21
39
|
import { LiveSocket, type WsFactory } from "./socket";
|
|
22
40
|
import {
|
|
@@ -27,6 +45,7 @@ import {
|
|
|
27
45
|
logLive,
|
|
28
46
|
markLiveEnded,
|
|
29
47
|
openSuggestions,
|
|
48
|
+
setLevel,
|
|
30
49
|
type LiveShareRow,
|
|
31
50
|
} from "./store";
|
|
32
51
|
|
|
@@ -54,6 +73,18 @@ export function makeSocket(factory?: WsFactory): LiveSocket {
|
|
|
54
73
|
|
|
55
74
|
const socket = (): LiveSocket => (live.socket ??= makeSocket());
|
|
56
75
|
|
|
76
|
+
const fx: DriveFx = {
|
|
77
|
+
send: (id, action, data) => live.socket?.send(id, action, data),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const sessionOrNull = (id: string): Session | null => {
|
|
81
|
+
try {
|
|
82
|
+
return getSession(id);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
57
88
|
function stateOfSession(sessionId: string) {
|
|
58
89
|
const l = registry.live.get(sessionId);
|
|
59
90
|
const s = l?.state;
|
|
@@ -78,6 +109,7 @@ function attach(row: LiveShareRow): Runtime {
|
|
|
78
109
|
slowUntil: 0,
|
|
79
110
|
notice: null,
|
|
80
111
|
drops: 0,
|
|
112
|
+
drive: newDrive(row.id),
|
|
81
113
|
};
|
|
82
114
|
live.runtimes.set(row.id, rt);
|
|
83
115
|
try {
|
|
@@ -90,6 +122,7 @@ function attach(row: LiveShareRow): Runtime {
|
|
|
90
122
|
/** Stops publishing a share and drops its connection; the row stays as history. */
|
|
91
123
|
function stop(shareId: string, markEnded = false): void {
|
|
92
124
|
const rt = live.runtimes.get(shareId);
|
|
125
|
+
if (rt) releaseDrive(rt, "ended", fx);
|
|
93
126
|
if (markEnded) markLiveEnded(shareId);
|
|
94
127
|
if (rt?.flushTimer) clearTimeout(rt.flushTimer);
|
|
95
128
|
live.runtimes.delete(shareId);
|
|
@@ -101,6 +134,7 @@ export function startLive(): void {
|
|
|
101
134
|
if (live.started) return;
|
|
102
135
|
live.started = true;
|
|
103
136
|
addTap(onMessage);
|
|
137
|
+
addTap((sessionId, m) => onDriveMessage(sessionId, m, fx));
|
|
104
138
|
for (const row of activeLiveShares()) attach(row);
|
|
105
139
|
setInterval(sweep, SWEEP_MS).unref();
|
|
106
140
|
}
|
|
@@ -126,10 +160,14 @@ function runtimeFor(id: string): Runtime {
|
|
|
126
160
|
|
|
127
161
|
export async function createLive(
|
|
128
162
|
sessionId: string,
|
|
129
|
-
expiresIn: LiveExpiry
|
|
163
|
+
expiresIn: LiveExpiry,
|
|
164
|
+
level: LiveLevel = "suggest",
|
|
165
|
+
history = true
|
|
130
166
|
): Promise<LiveView> {
|
|
131
167
|
startLive();
|
|
132
168
|
const session = getSession(sessionId);
|
|
169
|
+
if (level === "drive" && driveRefusal(session))
|
|
170
|
+
throw new ShareError(`Drive: ${driveRefusal(session)}.`, 400);
|
|
133
171
|
if (activeLiveShareFor(sessionId))
|
|
134
172
|
throw new ShareError("This session already has a live link.", 409);
|
|
135
173
|
const ctx = shareContext();
|
|
@@ -138,7 +176,7 @@ export async function createLive(
|
|
|
138
176
|
.text.replace(/\s+/g, " ")
|
|
139
177
|
.trim()
|
|
140
178
|
.slice(0, 120) || "Chat";
|
|
141
|
-
const made = await createLiveRemote({ title, expiresIn });
|
|
179
|
+
const made = await createLiveRemote({ title, expiresIn, access: level });
|
|
142
180
|
const row: LiveShareRow = {
|
|
143
181
|
id: made.id,
|
|
144
182
|
session_id: sessionId,
|
|
@@ -149,15 +187,79 @@ export async function createLive(
|
|
|
149
187
|
created_at: new Date().toISOString(),
|
|
150
188
|
expires_at: made.expires_at,
|
|
151
189
|
ended_at: null,
|
|
190
|
+
level,
|
|
191
|
+
history: history ? 1 : 0,
|
|
152
192
|
};
|
|
153
193
|
insertLiveShare(row);
|
|
154
194
|
attach(row);
|
|
155
195
|
return viewOf(row.id)!;
|
|
156
196
|
}
|
|
157
197
|
|
|
198
|
+
export function changeLevel(id: string, level: LiveLevel): void {
|
|
199
|
+
const rt = runtimeFor(id);
|
|
200
|
+
if (!(level in LEVELS)) throw new ShareError("Unknown level.", 400);
|
|
201
|
+
const option = levelOptions(sessionOrNull(rt.share.session_id)).find(
|
|
202
|
+
(o) => o.value === level
|
|
203
|
+
);
|
|
204
|
+
if (!option?.enabled)
|
|
205
|
+
throw new ShareError(
|
|
206
|
+
`${LEVELS[level].label} isn't available${option?.reason ? `: ${option.reason}` : ""}.`,
|
|
207
|
+
400
|
|
208
|
+
);
|
|
209
|
+
if (rt.share.level === level) return;
|
|
210
|
+
// Synchronous and first: from here the machine treats the share as
|
|
211
|
+
// `level`, so a racing Drive prompt is refused. Then what Drive left goes.
|
|
212
|
+
rt.share = { ...rt.share, level };
|
|
213
|
+
setLevel(id, level);
|
|
214
|
+
if (level !== "drive") setWheel(rt, null, "downgraded", fx);
|
|
215
|
+
rt.notice = null;
|
|
216
|
+
live.socket?.send(id, "access", { level });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Gives the wheel to an admitted guest of a Drive share, or takes it back (null). */
|
|
220
|
+
export function giveWheel(id: string, guestId: string | null): void {
|
|
221
|
+
const rt = runtimeFor(id);
|
|
222
|
+
if (rt.share.level !== "drive")
|
|
223
|
+
throw new ShareError("Drive is off for this share.", 409);
|
|
224
|
+
if (guestId) {
|
|
225
|
+
const g = rt.guests.get(guestId);
|
|
226
|
+
if (g?.status !== "admitted" || !admittedGuestIds(id).has(guestId))
|
|
227
|
+
throw new ShareError("That guest isn't in the session.", 404);
|
|
228
|
+
const s = sessionOrNull(rt.share.session_id);
|
|
229
|
+
const why = s ? driveRefusal(s) : "This session is gone";
|
|
230
|
+
if (why) throw new ShareError(`Drive: ${why}.`, 409);
|
|
231
|
+
}
|
|
232
|
+
if ((rt.drive.wheel?.guestId ?? null) === guestId) return;
|
|
233
|
+
setWheel(rt, guestId, "taken_back", fx);
|
|
234
|
+
rt.notice = null;
|
|
235
|
+
live.socket?.send(id, "access", {
|
|
236
|
+
level: "drive",
|
|
237
|
+
driver_guest_id: guestId,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What the invite dialog offers for a session. */
|
|
242
|
+
export function inviteFor(sessionId: string): LiveInvite {
|
|
243
|
+
const count = Math.min(
|
|
244
|
+
visible(listItems(sessionId)).filter(
|
|
245
|
+
(i) => i.kind === "user" || i.kind === "assistant"
|
|
246
|
+
).length,
|
|
247
|
+
RING_ITEMS
|
|
248
|
+
);
|
|
249
|
+
return {
|
|
250
|
+
levels: levelOptions(sessionOrNull(sessionId)),
|
|
251
|
+
history: {
|
|
252
|
+
label: `Include the ${count} earlier message${count === 1 ? "" : "s"}`,
|
|
253
|
+
default: true,
|
|
254
|
+
count,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
158
259
|
/** Ends access: Connect disconnects every guest, then this machine lets go. */
|
|
159
260
|
export async function endLive(id: string): Promise<void> {
|
|
160
|
-
|
|
261
|
+
// Before anything awaits: no Drive prompt is taken in from here.
|
|
262
|
+
releaseDrive(runtimeFor(id), "ended", fx);
|
|
161
263
|
await revokeRemote(id);
|
|
162
264
|
markLiveEnded(id);
|
|
163
265
|
stop(id);
|
|
@@ -181,14 +283,23 @@ function logGuest(rt: Runtime, kind: "admit" | "deny" | "kick", g: Guest) {
|
|
|
181
283
|
});
|
|
182
284
|
}
|
|
183
285
|
|
|
184
|
-
export function admit(
|
|
286
|
+
export function admit(
|
|
287
|
+
id: string,
|
|
288
|
+
guestId: string,
|
|
289
|
+
opts: { drive?: boolean } = {}
|
|
290
|
+
): void {
|
|
185
291
|
const rt = runtimeFor(id);
|
|
186
292
|
const g = guestOf(rt, guestId);
|
|
187
293
|
if (g.status !== "pending") throw new ShareError("Already let in.", 409);
|
|
294
|
+
if (opts.drive && rt.share.level !== "drive")
|
|
295
|
+
throw new ShareError("Drive is off for this share.", 409);
|
|
296
|
+
if (opts.drive && rt.drive.wheel)
|
|
297
|
+
throw new ShareError("Someone else is driving.", 409);
|
|
188
298
|
logGuest(rt, "admit", g);
|
|
189
299
|
g.status = "admitted";
|
|
190
300
|
rt.notice = null;
|
|
191
301
|
live.socket?.send(id, "admit", { guest_id: g.id });
|
|
302
|
+
if (opts.drive) giveWheel(id, g.id);
|
|
192
303
|
}
|
|
193
304
|
|
|
194
305
|
export function deny(id: string, guestId: string): void {
|
|
@@ -203,6 +314,7 @@ export function deny(id: string, guestId: string): void {
|
|
|
203
314
|
export function kick(id: string, guestId: string): void {
|
|
204
315
|
const rt = runtimeFor(id);
|
|
205
316
|
const g = guestOf(rt, guestId);
|
|
317
|
+
if (rt.drive.wheel?.guestId === g.id) setWheel(rt, null, "kicked", fx);
|
|
206
318
|
logGuest(rt, "kick", g);
|
|
207
319
|
rt.guests.delete(g.id);
|
|
208
320
|
live.socket?.send(id, "kick", { guest_id: g.id });
|
|
@@ -222,8 +334,10 @@ export async function sendSuggestion(
|
|
|
222
334
|
const rt = runtimeFor(id);
|
|
223
335
|
const card = openSuggestions(id).find((s) => s.id === suggestionId);
|
|
224
336
|
if (!card) throw new ShareError("That suggestion is gone.", 404);
|
|
225
|
-
const
|
|
226
|
-
if (!
|
|
337
|
+
const body = cleanBody(edited ?? card.text);
|
|
338
|
+
if (!body) throw new ShareError("The message is empty.", 400);
|
|
339
|
+
// It started as a guest's text, edited or not.
|
|
340
|
+
const text = noMentions(body);
|
|
227
341
|
const key = `${id}:${suggestionId}`;
|
|
228
342
|
if (sending.has(key)) throw new ShareError("Already sending.", 409);
|
|
229
343
|
sending.add(key);
|
|
@@ -231,6 +345,7 @@ export async function sendSuggestion(
|
|
|
231
345
|
await sendChatConfirmed(rt.share.session_id, {
|
|
232
346
|
text,
|
|
233
347
|
origin: { kind: "guest", label: card.guestName },
|
|
348
|
+
host: true,
|
|
234
349
|
});
|
|
235
350
|
logLive({
|
|
236
351
|
share_id: id,
|
|
@@ -283,6 +398,9 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
|
|
|
283
398
|
names.set(k, (names.get(k) ?? 0) + 1);
|
|
284
399
|
}
|
|
285
400
|
const admitted = admittedGuestIds(id);
|
|
401
|
+
const session = sessionOrNull(rt.share.session_id);
|
|
402
|
+
const levels = levelOptions(session);
|
|
403
|
+
const canDrive = !!levels.find((l) => l.value === "drive")?.enabled;
|
|
286
404
|
return {
|
|
287
405
|
id,
|
|
288
406
|
sessionId: rt.share.session_id,
|
|
@@ -293,6 +411,10 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
|
|
|
293
411
|
maxGuests: rt.share.max_guests,
|
|
294
412
|
connected: live.socket?.connected ?? false,
|
|
295
413
|
notice: rt.notice,
|
|
414
|
+
level: rt.share.level,
|
|
415
|
+
levels,
|
|
416
|
+
history: rt.share.history === 1,
|
|
417
|
+
...driveView(rt),
|
|
296
418
|
guests: shown
|
|
297
419
|
.filter((g) => g.status === "pending" || admitted.has(g.id))
|
|
298
420
|
.map((g) => ({
|
|
@@ -302,6 +424,7 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
|
|
|
302
424
|
online: g.online,
|
|
303
425
|
duplicateName:
|
|
304
426
|
(names.get(g.name.normalize("NFKC").toLowerCase()) ?? 0) > 1,
|
|
427
|
+
actions: guestActions(rt, g, canDrive),
|
|
305
428
|
})),
|
|
306
429
|
suggestions: openSuggestions(id),
|
|
307
430
|
};
|
package/lib/live/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { db } from "../db";
|
|
2
|
+
import type { LiveLevel } from "./types";
|
|
2
3
|
|
|
3
4
|
export interface LiveShareRow {
|
|
4
5
|
id: string;
|
|
@@ -10,6 +11,9 @@ export interface LiveShareRow {
|
|
|
10
11
|
created_at: string;
|
|
11
12
|
expires_at: string | null;
|
|
12
13
|
ended_at: string | null;
|
|
14
|
+
level: LiveLevel;
|
|
15
|
+
/** 1: guests get the chat from before the link was made. */
|
|
16
|
+
history: number;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export type LogKind =
|
|
@@ -19,7 +23,14 @@ export type LogKind =
|
|
|
19
23
|
| "kick"
|
|
20
24
|
| "suggestion"
|
|
21
25
|
| "sent"
|
|
22
|
-
| "dismissed"
|
|
26
|
+
| "dismissed"
|
|
27
|
+
| "level"
|
|
28
|
+
// Drive: who holds the wheel (guest_id, ref = the wheel's own id; both
|
|
29
|
+
// null when taken back), a prompt they sent (ref = its id), and why a
|
|
30
|
+
// Drive's turn or queue was stopped (ref = the reason).
|
|
31
|
+
| "wheel"
|
|
32
|
+
| "drive_prompt"
|
|
33
|
+
| "drive_stopped";
|
|
23
34
|
|
|
24
35
|
export interface LogRow {
|
|
25
36
|
id: number;
|
|
@@ -36,8 +47,8 @@ export interface LogRow {
|
|
|
36
47
|
export function insertLiveShare(row: LiveShareRow): void {
|
|
37
48
|
db.prepare(
|
|
38
49
|
`INSERT INTO live_shares
|
|
39
|
-
(id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at)
|
|
40
|
-
VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at)`
|
|
50
|
+
(id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at, level, history)
|
|
51
|
+
VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at, @level, @history)`
|
|
41
52
|
).run(row);
|
|
42
53
|
}
|
|
43
54
|
|
|
@@ -70,6 +81,19 @@ export function markLiveEnded(id: string): void {
|
|
|
70
81
|
).run(new Date().toISOString(), id);
|
|
71
82
|
}
|
|
72
83
|
|
|
84
|
+
export function setLevel(id: string, level: LiveLevel): void {
|
|
85
|
+
db.prepare(`UPDATE live_shares SET level = ? WHERE id = ?`).run(level, id);
|
|
86
|
+
logLive({
|
|
87
|
+
share_id: id,
|
|
88
|
+
kind: "level",
|
|
89
|
+
guest_id: null,
|
|
90
|
+
guest_name: null,
|
|
91
|
+
ref: level,
|
|
92
|
+
text: null,
|
|
93
|
+
sent_text: null,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
73
97
|
export function logLive(
|
|
74
98
|
row: Omit<LogRow, "id" | "at"> & { at?: number }
|
|
75
99
|
): void {
|
|
@@ -130,6 +154,33 @@ export function admittedGuestIds(shareId: string): Set<string> {
|
|
|
130
154
|
return out;
|
|
131
155
|
}
|
|
132
156
|
|
|
157
|
+
/** Who holds the wheel, from the log: a kick or a level other than Drive drops it. */
|
|
158
|
+
export function wheelOf(
|
|
159
|
+
shareId: string
|
|
160
|
+
): { guestId: string; wheel: string } | null {
|
|
161
|
+
let w: { guestId: string; wheel: string } | null = null;
|
|
162
|
+
for (const l of listLog(shareId)) {
|
|
163
|
+
if (l.kind === "wheel")
|
|
164
|
+
w = l.guest_id && l.ref ? { guestId: l.guest_id, wheel: l.ref } : null;
|
|
165
|
+
else if (l.kind === "level" && l.ref !== "drive") w = null;
|
|
166
|
+
else if (
|
|
167
|
+
(l.kind === "kick" || l.kind === "deny") &&
|
|
168
|
+
l.guest_id === w?.guestId
|
|
169
|
+
)
|
|
170
|
+
w = null;
|
|
171
|
+
}
|
|
172
|
+
return w;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Whether a Drive prompt with this id was ever logged for the share. */
|
|
176
|
+
export function hasDrivePrompt(shareId: string, ref: string): boolean {
|
|
177
|
+
return !!db
|
|
178
|
+
.prepare(
|
|
179
|
+
`SELECT 1 FROM live_share_log WHERE share_id = ? AND kind = 'drive_prompt' AND ref = ? LIMIT 1`
|
|
180
|
+
)
|
|
181
|
+
.get(shareId, ref);
|
|
182
|
+
}
|
|
183
|
+
|
|
133
184
|
/** Whether a suggestion with this id was ever logged for the share. */
|
|
134
185
|
export function hasSuggestion(shareId: string, ref: string): boolean {
|
|
135
186
|
return !!db
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* For tests: a fake Connect (its control API and its cable) and a Drive-ready
|
|
3
|
+
* task session, so a test can mint a live share, play Connect's side of the
|
|
4
|
+
* socket and watch what goes up. Import it before ./service.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import http from "http";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
import type { AddressInfo } from "net";
|
|
13
|
+
import { tempDir } from "@/lib/test-temp";
|
|
14
|
+
import type { WorkerCommand } from "@/lib/chat/worker/protocol";
|
|
15
|
+
|
|
16
|
+
export const SHARE = "live00000001";
|
|
17
|
+
|
|
18
|
+
export async function liveHarness() {
|
|
19
|
+
const dir = tempDir("agentos-live-");
|
|
20
|
+
const keys = crypto.generateKeyPairSync("ed25519");
|
|
21
|
+
const seen: { method: string; url: string; body: string }[] = [];
|
|
22
|
+
const server = http.createServer((req, res) => {
|
|
23
|
+
let body = "";
|
|
24
|
+
req.on("data", (c) => (body += c));
|
|
25
|
+
req.on("end", () => {
|
|
26
|
+
seen.push({ method: req.method!, url: req.url!, body });
|
|
27
|
+
const mint = req.method === "POST" && req.url!.endsWith("/live-shares");
|
|
28
|
+
res.writeHead(mint ? 201 : 200, { "Content-Type": "application/json" });
|
|
29
|
+
res.end(
|
|
30
|
+
JSON.stringify(
|
|
31
|
+
mint
|
|
32
|
+
? {
|
|
33
|
+
id: SHARE,
|
|
34
|
+
url: "https://runagentos.com/s/tok",
|
|
35
|
+
status: "active",
|
|
36
|
+
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
|
37
|
+
ends_with_session: false,
|
|
38
|
+
max_guests: 5,
|
|
39
|
+
}
|
|
40
|
+
: { id: SHARE, status: "revoked" }
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
46
|
+
process.env.AGENTOS_CONNECT_DIR = dir;
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
path.join(dir, "connect.json"),
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
machineId: "abcd1234",
|
|
51
|
+
hostname: "m-0123456789abcdef.runagentos.com",
|
|
52
|
+
apiUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
|
53
|
+
enabled: true,
|
|
54
|
+
}),
|
|
55
|
+
{ mode: 0o600 }
|
|
56
|
+
);
|
|
57
|
+
fs.writeFileSync(
|
|
58
|
+
path.join(dir, "machine.key"),
|
|
59
|
+
keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
60
|
+
{ mode: 0o600 }
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const { live } = await import("./runtime");
|
|
64
|
+
const svc = await import("./service");
|
|
65
|
+
const { getDb } = await import("@/lib/db");
|
|
66
|
+
const { createProject } = await import("@/lib/projects");
|
|
67
|
+
const { seedSession } = await import("@/lib/orchestrator/testing");
|
|
68
|
+
const { writePart } = await import("@/lib/db/session-parts");
|
|
69
|
+
const { partOf } = await import("@/lib/db/session-parts/columns");
|
|
70
|
+
|
|
71
|
+
/** Sets session columns, wherever each one lives. */
|
|
72
|
+
function setColumns(id: string, set: Record<string, unknown>) {
|
|
73
|
+
for (const [k, v] of Object.entries(set)) {
|
|
74
|
+
const part = partOf(k);
|
|
75
|
+
if (part) writePart(getDb(), part, id, { [k]: v } as never);
|
|
76
|
+
else
|
|
77
|
+
getDb().prepare(`UPDATE sessions SET ${k} = ? WHERE id = ?`).run(v, id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Fake {
|
|
82
|
+
out: Record<string, unknown>[];
|
|
83
|
+
handlers: Record<string, (d?: unknown) => void>;
|
|
84
|
+
}
|
|
85
|
+
let fake: Fake | null = null;
|
|
86
|
+
const factory = () => {
|
|
87
|
+
const f: Fake = { out: [], handlers: {} };
|
|
88
|
+
fake = f;
|
|
89
|
+
return {
|
|
90
|
+
readyState: 1,
|
|
91
|
+
send: (d: string) => f.out.push(JSON.parse(d)),
|
|
92
|
+
close: () => {},
|
|
93
|
+
terminate: () => {},
|
|
94
|
+
on: (e: string, fn: (d?: unknown) => void) => {
|
|
95
|
+
f.handlers[e] = fn;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const ident = JSON.stringify({ channel: "LiveHostChannel", share_id: SHARE });
|
|
100
|
+
|
|
101
|
+
/** A frame from Connect, as it arrives on the cable. */
|
|
102
|
+
const down = (frame: Record<string, unknown>) =>
|
|
103
|
+
fake!.handlers.message(
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
identifier: ident,
|
|
106
|
+
message: { share_id: SHARE, ...frame },
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/** What this machine sent up, by action. */
|
|
111
|
+
const up = (action?: string) =>
|
|
112
|
+
fake!.out
|
|
113
|
+
.filter((m) => m.command === "message")
|
|
114
|
+
.map((m) => JSON.parse(m.data as string) as Record<string, unknown>)
|
|
115
|
+
.filter((m) => !action || m.action === action);
|
|
116
|
+
|
|
117
|
+
/** A Claude task chat in its own worktree: what Drive needs. */
|
|
118
|
+
function taskChat(
|
|
119
|
+
cwd = tempDir("agentos-wt-"),
|
|
120
|
+
change: Record<string, unknown> = {}
|
|
121
|
+
): string {
|
|
122
|
+
const project = createProject({
|
|
123
|
+
name: `p-${randomUUID().slice(0, 6)}`,
|
|
124
|
+
workingDirectory: tempDir("agentos-main-"),
|
|
125
|
+
});
|
|
126
|
+
const id = seedSession({
|
|
127
|
+
projectId: project.id,
|
|
128
|
+
name: "Pairing",
|
|
129
|
+
view: "chat",
|
|
130
|
+
task: true,
|
|
131
|
+
});
|
|
132
|
+
setColumns(id, {
|
|
133
|
+
agent_type: "claude",
|
|
134
|
+
task_prompt: "build the thing",
|
|
135
|
+
worktree_path: cwd,
|
|
136
|
+
working_directory: cwd,
|
|
137
|
+
...change,
|
|
138
|
+
});
|
|
139
|
+
return id;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function open(
|
|
143
|
+
sessionId: string,
|
|
144
|
+
level: "watch" | "suggest" | "drive" = "drive"
|
|
145
|
+
) {
|
|
146
|
+
const view = await svc.createLive(sessionId, "1h", level, true);
|
|
147
|
+
fake!.handlers.message(JSON.stringify({ type: "welcome" }));
|
|
148
|
+
fake!.handlers.message(
|
|
149
|
+
JSON.stringify({ type: "confirm_subscription", identifier: ident })
|
|
150
|
+
);
|
|
151
|
+
return view;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function admit(gid = "g00000000001", name = "Ahmed", drive = false) {
|
|
155
|
+
down({ type: "join_request", guest: { id: gid, name } });
|
|
156
|
+
svc.admit(SHARE, gid, { drive });
|
|
157
|
+
down({ type: "presence", guest: { id: gid, name }, online: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { gatedHost, tick } = await import("@/lib/chat/worker/gate-testing");
|
|
161
|
+
const { registry, getSession } = await import("@/lib/chat/registry");
|
|
162
|
+
const { listQueue } = await import("@/lib/chat/queued");
|
|
163
|
+
const { onDriveMessage } = await import("./drive");
|
|
164
|
+
const publisher = await import("./publisher");
|
|
165
|
+
const fx = {
|
|
166
|
+
send: (id: string, action: string, data?: Record<string, unknown>) =>
|
|
167
|
+
live.socket?.send(id, action, data),
|
|
168
|
+
};
|
|
169
|
+
const settle = async () => {
|
|
170
|
+
for (let i = 0; i < 6; i++) await tick();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A task chat with its live share at `level`, a guest admitted (at the
|
|
175
|
+
* wheel unless suggest), and a real chat worker over a stub agent
|
|
176
|
+
* registered as its live one. The test file mocks the drivers with
|
|
177
|
+
* gate-testing's. `publish` also sends its items up, as the publisher
|
|
178
|
+
* would (paced, so statuses can arrive late).
|
|
179
|
+
*/
|
|
180
|
+
async function driving(
|
|
181
|
+
opts: {
|
|
182
|
+
level?: "suggest" | "drive";
|
|
183
|
+
guestId?: string;
|
|
184
|
+
publish?: boolean;
|
|
185
|
+
} = {}
|
|
186
|
+
) {
|
|
187
|
+
const guestId = opts.guestId ?? "g00000000001";
|
|
188
|
+
const sessionId = taskChat();
|
|
189
|
+
const session = getSession(sessionId);
|
|
190
|
+
const commands: WorkerCommand[] = [];
|
|
191
|
+
const w = await gatedHost(session, session.worktree_path!, (e) => {
|
|
192
|
+
const entry = registry.live.get(sessionId);
|
|
193
|
+
if (e.type === "state" && entry) entry.state = e.state;
|
|
194
|
+
if (e.type === "item" || e.type === "state") {
|
|
195
|
+
onDriveMessage(sessionId, e, fx);
|
|
196
|
+
if (opts.publish) publisher.onMessage(sessionId, e);
|
|
197
|
+
}
|
|
198
|
+
if (e.type === "queue")
|
|
199
|
+
onDriveMessage(
|
|
200
|
+
sessionId,
|
|
201
|
+
{ type: "queue", queue: listQueue(sessionId) },
|
|
202
|
+
fx
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
registry.live.set(sessionId, {
|
|
206
|
+
worker: {
|
|
207
|
+
command: (c: WorkerCommand) => {
|
|
208
|
+
commands.push(c);
|
|
209
|
+
void w.host.handle(c);
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
state: "idle",
|
|
213
|
+
streaming: new Map(),
|
|
214
|
+
activity: null,
|
|
215
|
+
canGuestGate: true,
|
|
216
|
+
} as never);
|
|
217
|
+
await open(sessionId, opts.level ?? "drive");
|
|
218
|
+
admit(guestId, "Ahmed", opts.level !== "suggest");
|
|
219
|
+
const prompt = async (
|
|
220
|
+
id: string,
|
|
221
|
+
text = "add a test for the parser",
|
|
222
|
+
gid = guestId
|
|
223
|
+
) => {
|
|
224
|
+
down({
|
|
225
|
+
type: "drive_prompt",
|
|
226
|
+
id,
|
|
227
|
+
guest: { id: gid, name: "Ahmed" },
|
|
228
|
+
text,
|
|
229
|
+
});
|
|
230
|
+
await settle();
|
|
231
|
+
};
|
|
232
|
+
const statuses = (id?: string) =>
|
|
233
|
+
up("drive_status").filter((s) => !id || s.id === id);
|
|
234
|
+
const sends = () => commands.filter((c) => c.type === "send");
|
|
235
|
+
const done = () => {
|
|
236
|
+
w.host.close();
|
|
237
|
+
registry.live.delete(sessionId);
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
sessionId,
|
|
241
|
+
w,
|
|
242
|
+
commands,
|
|
243
|
+
sends,
|
|
244
|
+
prompt,
|
|
245
|
+
statuses,
|
|
246
|
+
done,
|
|
247
|
+
rt: () => live.runtimes.get(SHARE)!,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function reset() {
|
|
252
|
+
for (const id of [...live.runtimes.keys()]) live.runtimes.delete(id);
|
|
253
|
+
live.socket?.close();
|
|
254
|
+
live.socket = svc.makeSocket(factory as never);
|
|
255
|
+
fake = null;
|
|
256
|
+
seen.length = 0;
|
|
257
|
+
getDb().prepare(`DELETE FROM live_shares`).run();
|
|
258
|
+
getDb().prepare(`DELETE FROM live_share_log`).run();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function close() {
|
|
262
|
+
live.socket?.close();
|
|
263
|
+
server.close();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
live,
|
|
268
|
+
svc,
|
|
269
|
+
seen,
|
|
270
|
+
down,
|
|
271
|
+
up,
|
|
272
|
+
taskChat,
|
|
273
|
+
setColumns,
|
|
274
|
+
open,
|
|
275
|
+
driving,
|
|
276
|
+
settle,
|
|
277
|
+
admit,
|
|
278
|
+
reset,
|
|
279
|
+
close,
|
|
280
|
+
};
|
|
281
|
+
}
|