@saadnvd1/agent-os 0.3.513 → 0.3.516
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/app/api/devices/me/push/route.test.ts +209 -0
- package/app/api/devices/me/push/route.ts +115 -0
- package/app/api/devices/me/route.ts +12 -0
- package/lib/chat/registry.ts +8 -0
- package/lib/chat/store.ts +5 -0
- package/lib/connect/serve.ts +107 -31
- package/lib/connect/supervise.ts +110 -11
- package/lib/db/migrations.ts +28 -0
- package/lib/demo-reseed.ts +2 -0
- package/lib/notify/copy.ts +190 -0
- package/lib/notify/push-types.ts +77 -0
- package/lib/notify/redact.ts +57 -0
- package/lib/orchestrator/armed.ts +2 -0
- package/lib/orchestrator/checks.ts +3 -0
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/orchestrator/gates.ts +3 -0
- package/lib/orchestrator/merge-approvals.ts +2 -0
- package/lib/security/demo.ts +1 -0
- package/lib/security/device-me.ts +12 -0
- package/lib/security/devices.ts +156 -1
- package/lib/security/net.ts +47 -11
- package/lib/status/collect.ts +39 -51
- package/lib/status/dirty.ts +42 -0
- package/lib/status/incremental.ts +151 -0
- package/lib/status/pr-stages.ts +34 -31
- package/lib/tasks/session.ts +3 -0
- package/package.json +1 -1
- package/server.ts +1 -0
package/lib/security/devices.ts
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import crypto from "crypto";
|
|
7
7
|
import { getDb } from "@/lib/db";
|
|
8
|
+
import { enabledCategories } from "@/lib/notify/copy";
|
|
9
|
+
import type {
|
|
10
|
+
PushCategory,
|
|
11
|
+
PushEnvironment,
|
|
12
|
+
PushPlatform,
|
|
13
|
+
} from "@/lib/notify/push-types";
|
|
8
14
|
|
|
9
15
|
export interface Device {
|
|
10
16
|
id: string;
|
|
@@ -115,13 +121,162 @@ export function revokeDevice(id: string): boolean {
|
|
|
115
121
|
const changed =
|
|
116
122
|
getDb()
|
|
117
123
|
.prepare(
|
|
118
|
-
`UPDATE devices SET revoked_at = datetime('now')
|
|
124
|
+
`UPDATE devices SET revoked_at = datetime('now'),
|
|
125
|
+
push_token = NULL, push_platform = NULL, push_environment = NULL,
|
|
126
|
+
push_categories = NULL, push_updated_at = NULL, push_last_error = NULL
|
|
127
|
+
WHERE id = ? AND revoked_at IS NULL`
|
|
119
128
|
)
|
|
120
129
|
.run(id).changes > 0;
|
|
121
130
|
if (changed) dropDeviceSockets(id);
|
|
122
131
|
return changed;
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
export interface DevicePush {
|
|
135
|
+
token: string;
|
|
136
|
+
platform: PushPlatform;
|
|
137
|
+
environment: PushEnvironment;
|
|
138
|
+
/** What it receives: its saved list, or the defaults. */
|
|
139
|
+
categories: PushCategory[];
|
|
140
|
+
updatedAt: string;
|
|
141
|
+
lastError: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface PushRow {
|
|
145
|
+
push_token: string | null;
|
|
146
|
+
push_platform: PushPlatform;
|
|
147
|
+
push_environment: PushEnvironment;
|
|
148
|
+
push_categories: string | null;
|
|
149
|
+
push_updated_at: string;
|
|
150
|
+
push_last_error: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A corrupt saved list reads as "none saved", so the defaults apply.
|
|
154
|
+
function parseCategories(raw: string | null): unknown[] | null {
|
|
155
|
+
if (!raw) return null;
|
|
156
|
+
try {
|
|
157
|
+
const v = JSON.parse(raw);
|
|
158
|
+
return Array.isArray(v) ? v : null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const pushOf = (r: PushRow | undefined): DevicePush | null =>
|
|
165
|
+
r?.push_token
|
|
166
|
+
? {
|
|
167
|
+
token: r.push_token,
|
|
168
|
+
platform: r.push_platform,
|
|
169
|
+
environment: r.push_environment,
|
|
170
|
+
categories: enabledCategories(parseCategories(r.push_categories)),
|
|
171
|
+
updatedAt: r.push_updated_at,
|
|
172
|
+
lastError: r.push_last_error,
|
|
173
|
+
}
|
|
174
|
+
: null;
|
|
175
|
+
|
|
176
|
+
const PUSH_COLS = `push_token, push_platform, push_environment, push_categories,
|
|
177
|
+
push_updated_at, push_last_error`;
|
|
178
|
+
|
|
179
|
+
export function getDevicePush(id: string): DevicePush | null {
|
|
180
|
+
return pushOf(
|
|
181
|
+
getDb()
|
|
182
|
+
.prepare(
|
|
183
|
+
`SELECT ${PUSH_COLS} FROM devices WHERE id = ? AND revoked_at IS NULL`
|
|
184
|
+
)
|
|
185
|
+
.get(id) as PushRow | undefined
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Every live device that can be sent a push, for the sender. */
|
|
190
|
+
export function listPushDevices(): { id: string; push: DevicePush }[] {
|
|
191
|
+
const rows = getDb()
|
|
192
|
+
.prepare(
|
|
193
|
+
`SELECT id, ${PUSH_COLS} FROM devices
|
|
194
|
+
WHERE revoked_at IS NULL AND push_token IS NOT NULL`
|
|
195
|
+
)
|
|
196
|
+
.all() as (PushRow & { id: string })[];
|
|
197
|
+
return rows.flatMap((r) => {
|
|
198
|
+
const push = pushOf(r);
|
|
199
|
+
return push ? [{ id: r.id, push }] : [];
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Registers or refreshes a device's push token. Idempotent. Saved categories
|
|
205
|
+
* are kept unless new ones are given. A token belongs to one device: if
|
|
206
|
+
* another row held it (a re-pair on the same phone), that row loses it so
|
|
207
|
+
* nothing is sent twice.
|
|
208
|
+
*/
|
|
209
|
+
export function setDevicePush(
|
|
210
|
+
id: string,
|
|
211
|
+
p: {
|
|
212
|
+
token: string;
|
|
213
|
+
platform: PushPlatform;
|
|
214
|
+
environment: PushEnvironment;
|
|
215
|
+
categories?: PushCategory[];
|
|
216
|
+
}
|
|
217
|
+
): boolean {
|
|
218
|
+
const db = getDb();
|
|
219
|
+
return db.transaction(() => {
|
|
220
|
+
db.prepare(
|
|
221
|
+
`UPDATE devices SET push_token = NULL, push_platform = NULL,
|
|
222
|
+
push_environment = NULL, push_categories = NULL,
|
|
223
|
+
push_updated_at = NULL, push_last_error = NULL
|
|
224
|
+
WHERE push_token = ? AND id != ?`
|
|
225
|
+
).run(p.token, id);
|
|
226
|
+
return (
|
|
227
|
+
db
|
|
228
|
+
.prepare(
|
|
229
|
+
`UPDATE devices SET push_token = ?, push_platform = ?, push_environment = ?,
|
|
230
|
+
push_categories = COALESCE(?, push_categories),
|
|
231
|
+
push_updated_at = datetime('now'), push_last_error = NULL
|
|
232
|
+
WHERE id = ? AND revoked_at IS NULL`
|
|
233
|
+
)
|
|
234
|
+
.run(
|
|
235
|
+
p.token,
|
|
236
|
+
p.platform,
|
|
237
|
+
p.environment,
|
|
238
|
+
p.categories ? JSON.stringify(p.categories) : null,
|
|
239
|
+
id
|
|
240
|
+
).changes > 0
|
|
241
|
+
);
|
|
242
|
+
})();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** False when the device has no push registered. */
|
|
246
|
+
export function setDevicePushCategories(
|
|
247
|
+
id: string,
|
|
248
|
+
categories: PushCategory[]
|
|
249
|
+
): boolean {
|
|
250
|
+
return (
|
|
251
|
+
getDb()
|
|
252
|
+
.prepare(
|
|
253
|
+
`UPDATE devices SET push_categories = ?, push_updated_at = datetime('now')
|
|
254
|
+
WHERE id = ? AND revoked_at IS NULL AND push_token IS NOT NULL`
|
|
255
|
+
)
|
|
256
|
+
.run(JSON.stringify(categories), id).changes > 0
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The sender records why a send failed, so the Devices page can say. */
|
|
261
|
+
export function setDevicePushError(id: string, error: string | null): void {
|
|
262
|
+
getDb()
|
|
263
|
+
.prepare(`UPDATE devices SET push_last_error = ? WHERE id = ?`)
|
|
264
|
+
.run(error ? error.slice(0, 200) : null, id);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function clearDevicePush(id: string): boolean {
|
|
268
|
+
return (
|
|
269
|
+
getDb()
|
|
270
|
+
.prepare(
|
|
271
|
+
`UPDATE devices SET push_token = NULL, push_platform = NULL,
|
|
272
|
+
push_environment = NULL, push_categories = NULL,
|
|
273
|
+
push_updated_at = NULL, push_last_error = NULL
|
|
274
|
+
WHERE id = ? AND push_token IS NOT NULL`
|
|
275
|
+
)
|
|
276
|
+
.run(id).changes > 0
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
125
280
|
// Open WebSockets per device, so revoking one cuts its live terminal too.
|
|
126
281
|
// On globalThis because server.ts and Next's route bundles load this module
|
|
127
282
|
// separately.
|
package/lib/security/net.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* another site's page (drive-by requests from any website the user visits).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isIP } from "net";
|
|
8
9
|
import os from "os";
|
|
9
10
|
|
|
10
11
|
const TAILSCALE_V4 = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./; // 100.64.0.0/10
|
|
@@ -72,6 +73,8 @@ export function bindAddresses(
|
|
|
72
73
|
export interface AccessPolicy {
|
|
73
74
|
bound: string[];
|
|
74
75
|
extraHosts: string[];
|
|
76
|
+
/** This machine's Connect address (connect.json), when it has one. */
|
|
77
|
+
machineHost?: () => string | null;
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
const LOCAL_NAMES = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
@@ -82,6 +85,23 @@ function hostname(hostOrUrlHost: string): string {
|
|
|
82
85
|
return h.replace(/:\d+$/, "");
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
// Every Connect machine is m-<16 hex>.runagentos.com, so all of them are
|
|
89
|
+
// the same site: another machine's page is never one of ours.
|
|
90
|
+
const MACHINE_HOST = /^m-[0-9a-f]{16}\.runagentos\.com$/;
|
|
91
|
+
|
|
92
|
+
const machineHostOf = (policy: AccessPolicy) =>
|
|
93
|
+
policy.machineHost?.()?.toLowerCase() || null;
|
|
94
|
+
|
|
95
|
+
/** Names that are this machine whatever it's bound to. */
|
|
96
|
+
function ownHost(h: string, policy: AccessPolicy): boolean {
|
|
97
|
+
return (
|
|
98
|
+
LOCAL_NAMES.has(h) ||
|
|
99
|
+
(h !== "0.0.0.0" && policy.bound.includes(h)) ||
|
|
100
|
+
policy.extraHosts.includes(h) ||
|
|
101
|
+
machineHostOf(policy) === h
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
85
105
|
export function hostAllowed(
|
|
86
106
|
host: string | undefined,
|
|
87
107
|
policy: AccessPolicy
|
|
@@ -89,25 +109,39 @@ export function hostAllowed(
|
|
|
89
109
|
if (!host) return false;
|
|
90
110
|
if (policy.bound.includes("0.0.0.0")) return true;
|
|
91
111
|
const h = hostname(host);
|
|
92
|
-
return (
|
|
93
|
-
LOCAL_NAMES.has(h) ||
|
|
94
|
-
policy.bound.includes(h) ||
|
|
95
|
-
h.endsWith(".ts.net") ||
|
|
96
|
-
policy.extraHosts.includes(h)
|
|
97
|
-
);
|
|
112
|
+
return ownHost(h, policy) || h.endsWith(".ts.net");
|
|
98
113
|
}
|
|
99
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A page may talk to this machine only from one of this machine's own
|
|
117
|
+
* origins, whatever AGENTOS_BIND says: its loopback, bound and configured
|
|
118
|
+
* names, its own Connect address, or the host the request itself names when
|
|
119
|
+
* that is one of its addresses (a page served here on any of its ports; the
|
|
120
|
+
* mobile app's native socket sends that one). A wildcard bind widens the
|
|
121
|
+
* Host check, never this one: not another machine's m- address, not a name
|
|
122
|
+
* rebound to this machine's address.
|
|
123
|
+
*/
|
|
100
124
|
export function originAllowed(
|
|
101
125
|
origin: string | undefined,
|
|
102
|
-
policy: AccessPolicy
|
|
126
|
+
policy: AccessPolicy,
|
|
127
|
+
requestHost?: string
|
|
103
128
|
): boolean {
|
|
104
|
-
if (!origin) return true; // not a browser page: curl, aos,
|
|
129
|
+
if (!origin) return true; // not a browser page: curl, aos, peers, the app
|
|
105
130
|
if (origin === "null") return false;
|
|
131
|
+
let h: string;
|
|
106
132
|
try {
|
|
107
|
-
|
|
133
|
+
h = hostname(new URL(origin).host);
|
|
108
134
|
} catch {
|
|
109
135
|
return false;
|
|
110
136
|
}
|
|
137
|
+
if (!h) return false;
|
|
138
|
+
if (MACHINE_HOST.test(h)) return h === machineHostOf(policy);
|
|
139
|
+
if (ownHost(h, policy)) return true;
|
|
140
|
+
return (
|
|
141
|
+
!!requestHost &&
|
|
142
|
+
h === hostname(requestHost) &&
|
|
143
|
+
(h.endsWith(".ts.net") || isIP(h) !== 0)
|
|
144
|
+
);
|
|
111
145
|
}
|
|
112
146
|
|
|
113
147
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
@@ -145,7 +179,7 @@ export function requestAllowed(
|
|
|
145
179
|
if (!req.url?.startsWith("/api/")) return true;
|
|
146
180
|
if (req.fetchSite === "cross-site") return crossSiteNavigationAllowed(req);
|
|
147
181
|
if (!SAFE_METHODS.has((req.method ?? "GET").toUpperCase())) {
|
|
148
|
-
return originAllowed(req.origin, policy);
|
|
182
|
+
return originAllowed(req.origin, policy, req.host);
|
|
149
183
|
}
|
|
150
184
|
return true;
|
|
151
185
|
}
|
|
@@ -154,5 +188,7 @@ export function upgradeAllowed(
|
|
|
154
188
|
req: { host?: string; origin?: string },
|
|
155
189
|
policy: AccessPolicy
|
|
156
190
|
): boolean {
|
|
157
|
-
return
|
|
191
|
+
return (
|
|
192
|
+
hostAllowed(req.host, policy) && originAllowed(req.origin, policy, req.host)
|
|
193
|
+
);
|
|
158
194
|
}
|
package/lib/status/collect.ts
CHANGED
|
@@ -19,7 +19,8 @@ import {
|
|
|
19
19
|
getProviderIdFromSessionName,
|
|
20
20
|
getSessionIdFromName,
|
|
21
21
|
} from "../providers/registry";
|
|
22
|
-
import { getDb
|
|
22
|
+
import { getDb } from "../db";
|
|
23
|
+
import { beginPass, chatMemo } from "./incremental";
|
|
23
24
|
import { chatCompacting, chatState } from "../chat/runner";
|
|
24
25
|
import { chatActivityLine, chatStall } from "../chat/activity";
|
|
25
26
|
import {
|
|
@@ -243,32 +244,16 @@ function getAgentTypeFromSessionName(sessionName: string): AgentType {
|
|
|
243
244
|
return getProviderIdFromSessionName(sessionName) || "claude";
|
|
244
245
|
}
|
|
245
246
|
|
|
246
|
-
function
|
|
247
|
-
return db
|
|
248
|
-
.prepare(
|
|
249
|
-
`SELECT id, view, updated_at, last_seen_at FROM sessions WHERE id = ?`
|
|
250
|
-
)
|
|
251
|
-
.get(id) as
|
|
252
|
-
| Pick<Session, "id" | "view" | "updated_at" | "last_seen_at">
|
|
253
|
-
| undefined;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
async function collect(): Promise<StatusSnapshot> {
|
|
247
|
+
async function collect(full = false): Promise<StatusSnapshot> {
|
|
257
248
|
const sessions = await getTmuxSessions();
|
|
258
249
|
|
|
259
250
|
const db = getDb();
|
|
251
|
+
// Read once the sessions are listed, so what was kept is as new as it.
|
|
252
|
+
const pass = beginPass(Date.now(), full);
|
|
253
|
+
const rows = [...pass.rows.values()];
|
|
260
254
|
const panes = managedPanes(
|
|
261
255
|
sessions,
|
|
262
|
-
|
|
263
|
-
.prepare(
|
|
264
|
-
`SELECT id, tmux_name, agent_type, host_id FROM sessions WHERE archived_at IS NULL AND (view IS NULL OR view != 'chat')`
|
|
265
|
-
)
|
|
266
|
-
.all() as {
|
|
267
|
-
id: string;
|
|
268
|
-
tmux_name: string | null;
|
|
269
|
-
agent_type: string | null;
|
|
270
|
-
host_id: string | null;
|
|
271
|
-
}[],
|
|
256
|
+
rows.filter((s) => s.view !== "chat"),
|
|
272
257
|
(name) => UUID_PATTERN.test(name),
|
|
273
258
|
getSessionIdFromName
|
|
274
259
|
);
|
|
@@ -351,7 +336,7 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
351
336
|
lastLine,
|
|
352
337
|
agentType,
|
|
353
338
|
} of results) {
|
|
354
|
-
const row =
|
|
339
|
+
const row = pass.rows.get(id);
|
|
355
340
|
const shared = {
|
|
356
341
|
sessionName,
|
|
357
342
|
lastLine,
|
|
@@ -418,6 +403,8 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
418
403
|
// Update claude_session_id directly here instead of requiring separate API calls
|
|
419
404
|
for (const { id, claudeSessionId, agentType } of results) {
|
|
420
405
|
if (!claudeSessionId) continue;
|
|
406
|
+
// Already stored (as the rows above read it): nothing to write.
|
|
407
|
+
if (pass.rows.get(id)?.claude_session_id === claudeSessionId) continue;
|
|
421
408
|
if (agentType === "claude")
|
|
422
409
|
updateClaudeIdStmt.run(claudeSessionId, id, claudeSessionId);
|
|
423
410
|
else
|
|
@@ -432,14 +419,9 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
432
419
|
|
|
433
420
|
// A linked machine's sessions: its AgentOS says how they are.
|
|
434
421
|
const linked = linkedHostIds();
|
|
435
|
-
const mirrors = (
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
`SELECT id, tmux_name, agent_type, host_id FROM sessions
|
|
439
|
-
WHERE archived_at IS NULL AND host_id IS NOT NULL AND host_id != 'local'`
|
|
440
|
-
)
|
|
441
|
-
.all() as Pick<Session, "id" | "tmux_name" | "agent_type" | "host_id">[]
|
|
442
|
-
).filter((s) => linked.has(s.host_id));
|
|
422
|
+
const mirrors = rows.filter(
|
|
423
|
+
(s) => s.host_id && s.host_id !== "local" && linked.has(s.host_id)
|
|
424
|
+
);
|
|
443
425
|
for (const s of mirrors) {
|
|
444
426
|
const status = peerStatus(s.host_id, s.id);
|
|
445
427
|
if (status)
|
|
@@ -452,36 +434,39 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
452
434
|
|
|
453
435
|
// Chat sessions have no tmux pane: their live conversation is the status.
|
|
454
436
|
// A linked machine's chats are its own (above).
|
|
455
|
-
for (const session of
|
|
456
|
-
.
|
|
457
|
-
|
|
458
|
-
AND (host_id IS NULL OR host_id = 'local')`
|
|
459
|
-
)
|
|
460
|
-
.all() as Session[]) {
|
|
437
|
+
for (const session of rows.filter(
|
|
438
|
+
(s) => s.view === "chat" && (!s.host_id || s.host_id === "local")
|
|
439
|
+
)) {
|
|
461
440
|
const state = chatState(session.id);
|
|
462
|
-
const
|
|
463
|
-
|
|
441
|
+
const orchestrator = session.role === "orchestrator";
|
|
442
|
+
// What the database says; the rest below moves with time or the process.
|
|
443
|
+
const base = chatMemo(pass, session.id, orchestrator, state ?? "", () => {
|
|
444
|
+
const asks = orchestrator ? openAskCount(session.workspace_id) : 0;
|
|
445
|
+
return {
|
|
446
|
+
asks,
|
|
447
|
+
waiting: needsYou(session, state),
|
|
448
|
+
need: chatNeed(session, state, asks),
|
|
449
|
+
unread: isUnread(session, state),
|
|
450
|
+
lastTask: lastUserTask(session.id),
|
|
451
|
+
};
|
|
452
|
+
});
|
|
464
453
|
statusMap[session.id] = {
|
|
465
454
|
sessionName: session.tmux_name,
|
|
466
455
|
status:
|
|
467
|
-
state === "running"
|
|
468
|
-
|
|
469
|
-
: needsYou(session, state)
|
|
470
|
-
? "waiting"
|
|
471
|
-
: "idle",
|
|
472
|
-
task: chatActivityLine(session.id) ?? lastUserTask(session.id),
|
|
456
|
+
state === "running" ? "running" : base.waiting ? "waiting" : "idle",
|
|
457
|
+
task: chatActivityLine(session.id) ?? base.lastTask,
|
|
473
458
|
agentType: session.agent_type,
|
|
474
|
-
need:
|
|
475
|
-
unread:
|
|
459
|
+
need: base.need,
|
|
460
|
+
unread: base.unread,
|
|
476
461
|
...(chatStall(session.id) && { stalled: true }),
|
|
477
462
|
...(chatCompacting(session.id) && {
|
|
478
463
|
compacting: chatCompacting(session.id)!,
|
|
479
464
|
}),
|
|
480
|
-
...(
|
|
465
|
+
...(orchestrator && { asks: base.asks }),
|
|
481
466
|
};
|
|
482
467
|
}
|
|
483
468
|
|
|
484
|
-
addPrStages(statusMap);
|
|
469
|
+
addPrStages(statusMap, pass);
|
|
485
470
|
|
|
486
471
|
// Cleanup old trackers
|
|
487
472
|
statusDetector.cleanup();
|
|
@@ -492,8 +477,11 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
492
477
|
// Callers at the same moment share one pass over tmux.
|
|
493
478
|
let inflight: Promise<StatusSnapshot> | null = null;
|
|
494
479
|
|
|
495
|
-
|
|
496
|
-
|
|
480
|
+
/** `full`: recompute everything kept from earlier passes. */
|
|
481
|
+
export function collectStatuses({
|
|
482
|
+
full = false,
|
|
483
|
+
}: { full?: boolean } = {}): Promise<StatusSnapshot> {
|
|
484
|
+
inflight ??= collect(full).finally(() => {
|
|
497
485
|
inflight = null;
|
|
498
486
|
});
|
|
499
487
|
return inflight;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Which sessions' status inputs changed since the last pass, so a pass
|
|
2
|
+
// recomputes those and reuses the rest (lib/status/incremental.ts). Marked
|
|
3
|
+
// from the places that write them. No imports, so any module can call it.
|
|
4
|
+
//
|
|
5
|
+
// Shared by the custom server and the Next.js route bundles.
|
|
6
|
+
interface Dirty {
|
|
7
|
+
all: boolean;
|
|
8
|
+
// A session's id and the time before which it stays marked.
|
|
9
|
+
ids: Map<string, number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const g = globalThis as unknown as { __agentosStatusDirty?: Dirty };
|
|
13
|
+
const dirty: Dirty = (g.__agentosStatusDirty ??= {
|
|
14
|
+
all: false,
|
|
15
|
+
ids: new Map(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `id`'s inputs changed. With `settleMs` it stays marked, so every pass
|
|
20
|
+
* recomputes it, until a pass at or after that time: a write still on its
|
|
21
|
+
* way (a worker's buffered chat items) can't be cached half-seen.
|
|
22
|
+
*/
|
|
23
|
+
export function markStatusDirty(id: string, settleMs = 0): void {
|
|
24
|
+
const until = Date.now() + settleMs;
|
|
25
|
+
dirty.ids.set(id, Math.max(dirty.ids.get(id) ?? 0, until));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Something every session's status may depend on changed. */
|
|
29
|
+
export function markAllStatusDirty(): void {
|
|
30
|
+
dirty.all = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a pass starting at `now` recomputes; the marks it settles are cleared. */
|
|
34
|
+
export function takeDirty(now = Date.now()): {
|
|
35
|
+
all: boolean;
|
|
36
|
+
ids: Set<string>;
|
|
37
|
+
} {
|
|
38
|
+
const taken = { all: dirty.all, ids: new Set(dirty.ids.keys()) };
|
|
39
|
+
dirty.all = false;
|
|
40
|
+
for (const [id, until] of dirty.ids) if (until <= now) dirty.ids.delete(id);
|
|
41
|
+
return taken;
|
|
42
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// What a status pass keeps from the one before, so its cost follows what
|
|
2
|
+
// changed and not how many sessions there are:
|
|
3
|
+
// - the session rows, re-read only when `sessions` or `projects` moved
|
|
4
|
+
// (lib/db/changes.ts: every writer is seen) and compared to find the
|
|
5
|
+
// sessions that changed;
|
|
6
|
+
// - per chat and per task, what is worked out from the database, recomputed
|
|
7
|
+
// when its session is marked dirty (lib/status/dirty.ts).
|
|
8
|
+
// Every FULL_RECOMPUTE_MS a pass recomputes everything, so an invalidation
|
|
9
|
+
// nobody made self-heals.
|
|
10
|
+
import { getDb, type Session } from "../db";
|
|
11
|
+
import { changeWatcher } from "../db/changes";
|
|
12
|
+
import { takeDirty } from "./dirty";
|
|
13
|
+
|
|
14
|
+
export const FULL_RECOMPUTE_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
// A session row, and the workspace of its project.
|
|
17
|
+
export type SessionRow = Session & { project_workspace_id: string | null };
|
|
18
|
+
|
|
19
|
+
export interface Pass {
|
|
20
|
+
// Everything is recomputed.
|
|
21
|
+
full: boolean;
|
|
22
|
+
// Sessions whose inputs changed (all of them on a full pass).
|
|
23
|
+
dirty: Set<string>;
|
|
24
|
+
// An orchestrator's asks changed: they and the tasks that read them.
|
|
25
|
+
asksMoved: boolean;
|
|
26
|
+
rows: Map<string, SessionRow>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Entry<T> = { key: string; value: T };
|
|
30
|
+
|
|
31
|
+
interface Cache {
|
|
32
|
+
rows: Map<string, { row: SessionRow; json: string }>;
|
|
33
|
+
watch: (() => string[]) | null;
|
|
34
|
+
lastFullAt: number;
|
|
35
|
+
loaded: boolean;
|
|
36
|
+
chats: Map<string, Entry<unknown>>;
|
|
37
|
+
prs: Map<string, Entry<unknown>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const g = globalThis as unknown as { __agentosStatusCache?: Cache };
|
|
41
|
+
const cache: Cache = (g.__agentosStatusCache ??= {
|
|
42
|
+
rows: new Map(),
|
|
43
|
+
watch: null,
|
|
44
|
+
lastFullAt: 0,
|
|
45
|
+
loaded: false,
|
|
46
|
+
chats: new Map(),
|
|
47
|
+
prs: new Map(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Forget everything kept: the next pass computes it all. */
|
|
51
|
+
export function resetStatusCaches(): void {
|
|
52
|
+
cache.rows.clear();
|
|
53
|
+
cache.chats.clear();
|
|
54
|
+
cache.prs.clear();
|
|
55
|
+
cache.watch = null;
|
|
56
|
+
cache.loaded = false;
|
|
57
|
+
cache.lastFullAt = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function loadRows(dirty: Set<string>): void {
|
|
61
|
+
const rows = getDb()
|
|
62
|
+
.prepare(
|
|
63
|
+
`SELECT s.*, p.workspace_id AS project_workspace_id FROM sessions s
|
|
64
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
65
|
+
WHERE s.archived_at IS NULL`
|
|
66
|
+
)
|
|
67
|
+
.all() as SessionRow[];
|
|
68
|
+
const before = cache.rows;
|
|
69
|
+
cache.rows = new Map();
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
const json = JSON.stringify(row);
|
|
72
|
+
if (before.get(row.id)?.json !== json) dirty.add(row.id);
|
|
73
|
+
cache.rows.set(row.id, { row, json });
|
|
74
|
+
}
|
|
75
|
+
for (const id of before.keys())
|
|
76
|
+
if (!cache.rows.has(id)) {
|
|
77
|
+
cache.chats.delete(id);
|
|
78
|
+
cache.prs.delete(id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function beginPass(now = Date.now(), forceFull = false): Pass {
|
|
83
|
+
// Read before the rows, so a write between the two is seen next time.
|
|
84
|
+
const moved = (cache.watch ??= changeWatcher(getDb))();
|
|
85
|
+
const taken = takeDirty(now);
|
|
86
|
+
const full =
|
|
87
|
+
forceFull ||
|
|
88
|
+
!cache.loaded ||
|
|
89
|
+
taken.all ||
|
|
90
|
+
now - cache.lastFullAt >= FULL_RECOMPUTE_MS;
|
|
91
|
+
const dirty = taken.ids;
|
|
92
|
+
if (full || moved.includes("sessions") || moved.includes("projects")) {
|
|
93
|
+
loadRows(dirty);
|
|
94
|
+
cache.loaded = true;
|
|
95
|
+
}
|
|
96
|
+
if (full) {
|
|
97
|
+
cache.chats.clear();
|
|
98
|
+
cache.prs.clear();
|
|
99
|
+
cache.lastFullAt = now;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
full,
|
|
103
|
+
dirty,
|
|
104
|
+
asksMoved: moved.includes("orchestrator_asks"),
|
|
105
|
+
rows: new Map([...cache.rows].map(([id, e]) => [id, e.row])),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function remembered<T>(
|
|
110
|
+
store: Map<string, Entry<unknown>>,
|
|
111
|
+
id: string,
|
|
112
|
+
stale: boolean,
|
|
113
|
+
key: string,
|
|
114
|
+
compute: () => { value: T; keep: boolean }
|
|
115
|
+
): T {
|
|
116
|
+
const kept = store.get(id);
|
|
117
|
+
if (!stale && kept && kept.key === key) return kept.value as T;
|
|
118
|
+
const { value, keep } = compute();
|
|
119
|
+
if (keep) store.set(id, { key, value });
|
|
120
|
+
else store.delete(id);
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A chat's database-derived status, from the last pass unless it changed.
|
|
126
|
+
* `key` is what else it was computed from (its state).
|
|
127
|
+
*/
|
|
128
|
+
export function chatMemo<T>(
|
|
129
|
+
pass: Pass,
|
|
130
|
+
id: string,
|
|
131
|
+
orchestrator: boolean,
|
|
132
|
+
key: string,
|
|
133
|
+
compute: () => T
|
|
134
|
+
): T {
|
|
135
|
+
const stale =
|
|
136
|
+
pass.full || pass.dirty.has(id) || (orchestrator && pass.asksMoved);
|
|
137
|
+
return remembered(cache.chats, id, stale, key, () => ({
|
|
138
|
+
value: compute(),
|
|
139
|
+
keep: true,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A task's PR stage; `compute` says whether it can be kept (time moves it). */
|
|
144
|
+
export function prMemo<T>(
|
|
145
|
+
pass: Pass,
|
|
146
|
+
id: string,
|
|
147
|
+
compute: () => { value: T; keep: boolean }
|
|
148
|
+
): T {
|
|
149
|
+
const stale = pass.full || pass.dirty.has(id) || pass.asksMoved;
|
|
150
|
+
return remembered(cache.prs, id, stale, "", compute);
|
|
151
|
+
}
|