@hyperdrive.bot/fleet-server 0.3.159 → 0.3.160
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/dist/server/server/agent/agent-manager.d.ts +29 -0
- package/dist/server/server/agent/agent-manager.js +69 -5
- package/dist/server/server/agent/agent-timeline-store.d.ts +10 -0
- package/dist/server/server/agent/agent-timeline-store.js +54 -6
- package/dist/server/server/agent/blocker-log.d.ts +3 -1
- package/dist/server/server/agent/blocker-log.js +17 -5
- package/dist/server/server/agent/card-move-log.d.ts +3 -1
- package/dist/server/server/agent/card-move-log.js +21 -14
- package/dist/server/server/agent/import-sessions.js +1 -1
- package/dist/server/server/agent/jsonl-card-index.d.ts +53 -0
- package/dist/server/server/agent/jsonl-card-index.js +138 -0
- package/dist/server/server/agent/lifecycle-command.d.ts +18 -1
- package/dist/server/server/agent/lifecycle-command.js +21 -2
- package/dist/server/server/agent/mcp-shared.d.ts +1 -0
- package/dist/server/server/agent/provider-snapshot-manager.d.ts +9 -0
- package/dist/server/server/agent/provider-snapshot-manager.js +38 -1
- package/dist/server/server/agent/structured-generation-providers.d.ts +2 -1
- package/dist/server/server/agent/structured-generation-providers.js +21 -8
- package/dist/server/server/fleet/decision-service.d.ts +8 -0
- package/dist/server/server/fleet/decision-service.js +24 -1
- package/dist/server/server/fleet/fleet-controls.js +2 -1
- package/dist/server/server/fleet/fleet-reader.d.ts +12 -0
- package/dist/server/server/fleet/fleet-reader.js +48 -2
- package/dist/server/server/schedule/service.d.ts +4 -0
- package/dist/server/server/schedule/service.js +26 -5
- package/dist/server/server/schedule/store.d.ts +51 -0
- package/dist/server/server/schedule/store.js +184 -16
- package/dist/server/server/session/agent-updates/agent-updates-service.d.ts +16 -1
- package/dist/server/server/session/agent-updates/agent-updates-service.js +59 -2
- package/dist/server/server/session/provider/provider-catalog-session.d.ts +8 -0
- package/dist/server/server/session/provider/provider-catalog-session.js +21 -1
- package/dist/server/server/session.d.ts +16 -0
- package/dist/server/server/session.js +115 -19
- package/dist/server/server/websocket-server.d.ts +5 -0
- package/dist/server/server/websocket-server.js +10 -0
- package/dist/server/server/workspace-directory.d.ts +4 -1
- package/dist/server/server/workspace-directory.js +3 -2
- package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js → index-29775d90476d32ca65921d262ae06baa.js} +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-29775d90476d32ca65921d262ae06baa.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-29775d90476d32ca65921d262ae06baa.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js.map.br → index-29775d90476d32ca65921d262ae06baa.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js.map.gz → index-29775d90476d32ca65921d262ae06baa.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-1420ca2d21c343afea2c8e2da752cd54.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-1420ca2d21c343afea2c8e2da752cd54.js.gz +0 -0
|
@@ -1,13 +1,64 @@
|
|
|
1
1
|
import { type StoredSchedule } from "@hyperdrive.bot/fleet-protocol/schedule/types";
|
|
2
|
+
/**
|
|
3
|
+
* How many runs a schedule file keeps. Every run start and finish rewrites the
|
|
4
|
+
* whole file, and the history grew unbounded (5,510 runs, 2.3 MB in one real
|
|
5
|
+
* file), so the oldest runs are dropped at write time. How many finished runs
|
|
6
|
+
* were dropped is kept in `droppedRunCount`, so `maxRuns` completion still
|
|
7
|
+
* counts every run that ever happened.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SCHEDULE_RUNS_RETAINED = 200;
|
|
10
|
+
export declare function capScheduleRuns(schedule: StoredSchedule): StoredSchedule;
|
|
11
|
+
/**
|
|
12
|
+
* Three-way merge of a daemon write over a file that changed on disk since the
|
|
13
|
+
* daemon last read it. `base` is what the daemon read, `mine` what it wants to
|
|
14
|
+
* write, `theirs` what is on disk now. A top-level field the daemon did not
|
|
15
|
+
* change keeps the disk value; a field it changed keeps the daemon's. Runs are
|
|
16
|
+
* unioned by id, the daemon's copy of a run winning.
|
|
17
|
+
*/
|
|
18
|
+
export declare function mergeScheduleWrite(base: StoredSchedule, mine: StoredSchedule, theirs: StoredSchedule): StoredSchedule;
|
|
19
|
+
/**
|
|
20
|
+
* File-backed schedule store with an in-memory cache validated against disk.
|
|
21
|
+
*
|
|
22
|
+
* Every read stats the directory's `*.json` files (no read, no parse) and
|
|
23
|
+
* re-reads only files whose mtime or size changed, so a schedule written by
|
|
24
|
+
* hand or by another tool is still picked up by a running daemon, at a fraction
|
|
25
|
+
* of the cost of re-parsing 5 MB of JSON per call. Concurrent reads share one
|
|
26
|
+
* validation pass. A write whose file changed on disk since the daemon read it
|
|
27
|
+
* is merged over the disk copy (`mergeScheduleWrite`) rather than clobbering it.
|
|
28
|
+
*
|
|
29
|
+
* Returned objects are shared with the cache: treat them as read-only and write
|
|
30
|
+
* changes back through `put`.
|
|
31
|
+
*/
|
|
2
32
|
export declare class ScheduleStore {
|
|
3
33
|
private readonly dir;
|
|
34
|
+
private readonly cache;
|
|
35
|
+
private readonly stamps;
|
|
36
|
+
private validating;
|
|
37
|
+
private revision;
|
|
4
38
|
constructor(dir: string);
|
|
5
39
|
private filePath;
|
|
6
40
|
private ensureDir;
|
|
41
|
+
private readStamp;
|
|
42
|
+
private readScheduleFile;
|
|
43
|
+
/** Bring the cache in line with disk, re-reading only files that changed. */
|
|
44
|
+
private validate;
|
|
45
|
+
private validateNow;
|
|
7
46
|
list(): Promise<StoredSchedule[]>;
|
|
8
47
|
get(id: string): Promise<StoredSchedule | null>;
|
|
48
|
+
/**
|
|
49
|
+
* Delete temp files left by atomic writes that never renamed (a crash or kill
|
|
50
|
+
* mid-write). They are invisible to `list` but pile up on disk. Returns how
|
|
51
|
+
* many were removed.
|
|
52
|
+
*/
|
|
53
|
+
sweepOrphanedTempFiles(now?: number): Promise<number>;
|
|
9
54
|
create(schedule: Omit<StoredSchedule, "id">): Promise<StoredSchedule>;
|
|
10
55
|
put(schedule: StoredSchedule): Promise<void>;
|
|
11
56
|
delete(id: string): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Bumps whenever the cached set changes (a disk change seen on validation, a
|
|
59
|
+
* put, a delete), so a caller can reuse anything it derived from `list()`
|
|
60
|
+
* until the store actually changes.
|
|
61
|
+
*/
|
|
62
|
+
get version(): number;
|
|
12
63
|
}
|
|
13
64
|
//# sourceMappingURL=store.d.ts.map
|
|
@@ -1,14 +1,84 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { StoredScheduleSchema, } from "@hyperdrive.bot/fleet-protocol/schedule/types";
|
|
5
5
|
import { writeJsonFileAtomic } from "../atomic-file.js";
|
|
6
|
+
/**
|
|
7
|
+
* How many runs a schedule file keeps. Every run start and finish rewrites the
|
|
8
|
+
* whole file, and the history grew unbounded (5,510 runs, 2.3 MB in one real
|
|
9
|
+
* file), so the oldest runs are dropped at write time. How many finished runs
|
|
10
|
+
* were dropped is kept in `droppedRunCount`, so `maxRuns` completion still
|
|
11
|
+
* counts every run that ever happened.
|
|
12
|
+
*/
|
|
13
|
+
export const SCHEDULE_RUNS_RETAINED = 200;
|
|
14
|
+
/**
|
|
15
|
+
* A leftover `.<id>.json.<pid>.<ts>.<uuid>.tmp` from an interrupted atomic write
|
|
16
|
+
* is only swept when it is at least this old, so a write racing the sweep is
|
|
17
|
+
* never deleted from under itself.
|
|
18
|
+
*/
|
|
19
|
+
const ORPHAN_TMP_MIN_AGE_MS = 60000;
|
|
20
|
+
export function capScheduleRuns(schedule) {
|
|
21
|
+
if (schedule.runs.length <= SCHEDULE_RUNS_RETAINED) {
|
|
22
|
+
return schedule;
|
|
23
|
+
}
|
|
24
|
+
const dropped = schedule.runs.slice(0, schedule.runs.length - SCHEDULE_RUNS_RETAINED);
|
|
25
|
+
const droppedFinished = dropped.filter((run) => run.status !== "running").length;
|
|
26
|
+
return {
|
|
27
|
+
...schedule,
|
|
28
|
+
runs: schedule.runs.slice(-SCHEDULE_RUNS_RETAINED),
|
|
29
|
+
droppedRunCount: (schedule.droppedRunCount ?? 0) + droppedFinished,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function sameStamp(left, right) {
|
|
33
|
+
return (left !== undefined &&
|
|
34
|
+
right !== undefined &&
|
|
35
|
+
left.mtimeMs === right.mtimeMs &&
|
|
36
|
+
left.size === right.size);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Three-way merge of a daemon write over a file that changed on disk since the
|
|
40
|
+
* daemon last read it. `base` is what the daemon read, `mine` what it wants to
|
|
41
|
+
* write, `theirs` what is on disk now. A top-level field the daemon did not
|
|
42
|
+
* change keeps the disk value; a field it changed keeps the daemon's. Runs are
|
|
43
|
+
* unioned by id, the daemon's copy of a run winning.
|
|
44
|
+
*/
|
|
45
|
+
export function mergeScheduleWrite(base, mine, theirs) {
|
|
46
|
+
const merged = { ...theirs };
|
|
47
|
+
for (const key of Object.keys(mine)) {
|
|
48
|
+
if (key === "runs")
|
|
49
|
+
continue;
|
|
50
|
+
if (JSON.stringify(mine[key]) !== JSON.stringify(base[key])) {
|
|
51
|
+
merged[key] = mine[key];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const mineRunIds = new Set(mine.runs.map((run) => run.id));
|
|
55
|
+
const theirsOnly = theirs.runs.filter((run) => !mineRunIds.has(run.id));
|
|
56
|
+
merged.runs = [...theirsOnly, ...mine.runs].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
|
|
57
|
+
return merged;
|
|
58
|
+
}
|
|
6
59
|
function generateScheduleId() {
|
|
7
60
|
return randomBytes(4).toString("hex");
|
|
8
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* File-backed schedule store with an in-memory cache validated against disk.
|
|
64
|
+
*
|
|
65
|
+
* Every read stats the directory's `*.json` files (no read, no parse) and
|
|
66
|
+
* re-reads only files whose mtime or size changed, so a schedule written by
|
|
67
|
+
* hand or by another tool is still picked up by a running daemon, at a fraction
|
|
68
|
+
* of the cost of re-parsing 5 MB of JSON per call. Concurrent reads share one
|
|
69
|
+
* validation pass. A write whose file changed on disk since the daemon read it
|
|
70
|
+
* is merged over the disk copy (`mergeScheduleWrite`) rather than clobbering it.
|
|
71
|
+
*
|
|
72
|
+
* Returned objects are shared with the cache: treat them as read-only and write
|
|
73
|
+
* changes back through `put`.
|
|
74
|
+
*/
|
|
9
75
|
export class ScheduleStore {
|
|
10
76
|
constructor(dir) {
|
|
11
77
|
this.dir = dir;
|
|
78
|
+
this.cache = new Map();
|
|
79
|
+
this.stamps = new Map();
|
|
80
|
+
this.validating = null;
|
|
81
|
+
this.revision = 0;
|
|
12
82
|
}
|
|
13
83
|
filePath(id) {
|
|
14
84
|
return join(this.dir, `${id}.json`);
|
|
@@ -16,29 +86,99 @@ export class ScheduleStore {
|
|
|
16
86
|
async ensureDir() {
|
|
17
87
|
await mkdir(this.dir, { recursive: true });
|
|
18
88
|
}
|
|
19
|
-
async
|
|
89
|
+
async readStamp(path) {
|
|
90
|
+
try {
|
|
91
|
+
const info = await stat(path);
|
|
92
|
+
return { mtimeMs: info.mtimeMs, size: info.size };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code === "ENOENT")
|
|
96
|
+
return undefined;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async readScheduleFile(path) {
|
|
101
|
+
const content = await readFile(path, "utf-8");
|
|
102
|
+
return StoredScheduleSchema.parse(JSON.parse(content));
|
|
103
|
+
}
|
|
104
|
+
/** Bring the cache in line with disk, re-reading only files that changed. */
|
|
105
|
+
validate() {
|
|
106
|
+
if (!this.validating) {
|
|
107
|
+
this.validating = this.validateNow().finally(() => {
|
|
108
|
+
this.validating = null;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return this.validating;
|
|
112
|
+
}
|
|
113
|
+
async validateNow() {
|
|
20
114
|
await this.ensureDir();
|
|
21
115
|
const entries = await readdir(this.dir, { withFileTypes: true });
|
|
22
|
-
const
|
|
116
|
+
const names = entries
|
|
23
117
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
24
|
-
.map(
|
|
25
|
-
|
|
26
|
-
|
|
118
|
+
.map((entry) => entry.name);
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
let changed = false;
|
|
121
|
+
await Promise.all(names.map(async (name) => {
|
|
122
|
+
seen.add(name);
|
|
123
|
+
const path = join(this.dir, name);
|
|
124
|
+
const stamp = await this.readStamp(path);
|
|
125
|
+
if (!stamp)
|
|
126
|
+
return;
|
|
127
|
+
if (sameStamp(this.stamps.get(name), stamp))
|
|
128
|
+
return;
|
|
129
|
+
const schedule = await this.readScheduleFile(path);
|
|
130
|
+
this.cache.set(schedule.id, schedule);
|
|
131
|
+
this.stamps.set(name, stamp);
|
|
132
|
+
changed = true;
|
|
27
133
|
}));
|
|
28
|
-
|
|
134
|
+
for (const name of this.stamps.keys()) {
|
|
135
|
+
if (seen.has(name))
|
|
136
|
+
continue;
|
|
137
|
+
this.stamps.delete(name);
|
|
138
|
+
this.cache.delete(name.slice(0, -".json".length));
|
|
139
|
+
changed = true;
|
|
140
|
+
}
|
|
141
|
+
if (changed) {
|
|
142
|
+
this.revision += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async list() {
|
|
146
|
+
await this.validate();
|
|
147
|
+
return [...this.cache.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
29
148
|
}
|
|
30
149
|
async get(id) {
|
|
150
|
+
await this.validate();
|
|
151
|
+
return this.cache.get(id) ?? null;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Delete temp files left by atomic writes that never renamed (a crash or kill
|
|
155
|
+
* mid-write). They are invisible to `list` but pile up on disk. Returns how
|
|
156
|
+
* many were removed.
|
|
157
|
+
*/
|
|
158
|
+
async sweepOrphanedTempFiles(now = Date.now()) {
|
|
31
159
|
await this.ensureDir();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
160
|
+
const entries = await readdir(this.dir, { withFileTypes: true });
|
|
161
|
+
let removed = 0;
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (!entry.isFile() || !entry.name.startsWith(".") || !entry.name.endsWith(".tmp")) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const path = join(this.dir, entry.name);
|
|
167
|
+
try {
|
|
168
|
+
const info = await stat(path);
|
|
169
|
+
if (now - info.mtimeMs < ORPHAN_TMP_MIN_AGE_MS) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
await rm(path, { force: true });
|
|
173
|
+
removed += 1;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error.code !== "ENOENT") {
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
39
179
|
}
|
|
40
|
-
throw error;
|
|
41
180
|
}
|
|
181
|
+
return removed;
|
|
42
182
|
}
|
|
43
183
|
async create(schedule) {
|
|
44
184
|
const created = { ...schedule, id: generateScheduleId() };
|
|
@@ -47,11 +187,39 @@ export class ScheduleStore {
|
|
|
47
187
|
}
|
|
48
188
|
async put(schedule) {
|
|
49
189
|
await this.ensureDir();
|
|
50
|
-
|
|
190
|
+
const name = `${schedule.id}.json`;
|
|
191
|
+
const path = this.filePath(schedule.id);
|
|
192
|
+
let next = schedule;
|
|
193
|
+
// What the daemon last read for this id, and the file stamp it read it at.
|
|
194
|
+
const base = this.cache.get(schedule.id);
|
|
195
|
+
const known = this.stamps.get(name);
|
|
196
|
+
const onDisk = known ? await this.readStamp(path) : undefined;
|
|
197
|
+
if (base && known && onDisk && !sameStamp(known, onDisk)) {
|
|
198
|
+
// Edited behind the daemon's back since it was read: merge, never clobber.
|
|
199
|
+
next = mergeScheduleWrite(base, schedule, await this.readScheduleFile(path));
|
|
200
|
+
}
|
|
201
|
+
const capped = capScheduleRuns(next);
|
|
202
|
+
await writeJsonFileAtomic(path, capped);
|
|
203
|
+
const stamp = await this.readStamp(path);
|
|
204
|
+
if (stamp)
|
|
205
|
+
this.stamps.set(name, stamp);
|
|
206
|
+
this.cache.set(capped.id, capped);
|
|
207
|
+
this.revision += 1;
|
|
51
208
|
}
|
|
52
209
|
async delete(id) {
|
|
53
210
|
await this.ensureDir();
|
|
54
211
|
await rm(this.filePath(id), { force: true });
|
|
212
|
+
this.cache.delete(id);
|
|
213
|
+
this.stamps.delete(`${id}.json`);
|
|
214
|
+
this.revision += 1;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Bumps whenever the cached set changes (a disk change seen on validation, a
|
|
218
|
+
* put, a delete), so a caller can reuse anything it derived from `list()`
|
|
219
|
+
* until the store actually changes.
|
|
220
|
+
*/
|
|
221
|
+
get version() {
|
|
222
|
+
return this.revision;
|
|
55
223
|
}
|
|
56
224
|
}
|
|
57
225
|
//# sourceMappingURL=store.js.map
|
|
@@ -30,7 +30,14 @@ export interface AgentUpdatesService {
|
|
|
30
30
|
}): void;
|
|
31
31
|
clearSubscription(subscriptionId: string): void;
|
|
32
32
|
hasSubscription(): boolean;
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Emit the agent's update, then its workspace update. Workspace updates are
|
|
35
|
+
* coalesced (see WORKSPACE_UPDATE_COALESCE_MS) unless `immediate`, which a
|
|
36
|
+
* request handler that replies after this resolves should pass.
|
|
37
|
+
*/
|
|
38
|
+
forwardLiveAgent(agent: ManagedAgent, options?: {
|
|
39
|
+
immediate?: boolean;
|
|
40
|
+
}): Promise<void>;
|
|
34
41
|
emitStoredRecord(record: StoredAgentRecord): Promise<AgentSnapshotPayload>;
|
|
35
42
|
removeAgent(agentId: string): void;
|
|
36
43
|
dispose(): void;
|
|
@@ -43,7 +50,15 @@ export interface AgentUpdatesServiceDeps {
|
|
|
43
50
|
buildProjectPlacementForWorkspaceId(workspaceId: string): Promise<ProjectPlacementPayload | null>;
|
|
44
51
|
emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void>;
|
|
45
52
|
logger: pino.Logger;
|
|
53
|
+
/** Coalescing window for workspace updates; defaults to WORKSPACE_UPDATE_COALESCE_MS. */
|
|
54
|
+
workspaceUpdateCoalesceMs?: number;
|
|
46
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* A streaming agent emits agent_state many times a second, and each one used to
|
|
58
|
+
* rebuild its workspace descriptor on the spot. Workspace updates are collected
|
|
59
|
+
* per workspace for this long and flushed once.
|
|
60
|
+
*/
|
|
61
|
+
export declare const WORKSPACE_UPDATE_COALESCE_MS = 100;
|
|
47
62
|
/**
|
|
48
63
|
* Pure predicate shared by the live subscription stream and the snapshot listing
|
|
49
64
|
* pager: does an agent (with its resolved project placement) satisfy a
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { resolveEffectiveThinkingOptionId } from "../../agent/agent-projections.js";
|
|
2
|
+
/**
|
|
3
|
+
* A streaming agent emits agent_state many times a second, and each one used to
|
|
4
|
+
* rebuild its workspace descriptor on the spot. Workspace updates are collected
|
|
5
|
+
* per workspace for this long and flushed once.
|
|
6
|
+
*/
|
|
7
|
+
export const WORKSPACE_UPDATE_COALESCE_MS = 100;
|
|
2
8
|
function agentThinkingOptionMatchesFilter(agent, filter) {
|
|
3
9
|
if (filter.thinkingOptionId === undefined) {
|
|
4
10
|
return true;
|
|
@@ -64,6 +70,43 @@ function agentUpdateTargetId(update) {
|
|
|
64
70
|
}
|
|
65
71
|
export function createAgentUpdatesService(deps) {
|
|
66
72
|
let subscription = null;
|
|
73
|
+
const coalesceMs = deps.workspaceUpdateCoalesceMs ?? WORKSPACE_UPDATE_COALESCE_MS;
|
|
74
|
+
let pendingWorkspaceIds = new Set();
|
|
75
|
+
let disposed = false;
|
|
76
|
+
let pendingWorkspaceFlush = null;
|
|
77
|
+
async function flushWorkspaceUpdates() {
|
|
78
|
+
const flush = pendingWorkspaceFlush;
|
|
79
|
+
const workspaceIds = pendingWorkspaceIds;
|
|
80
|
+
pendingWorkspaceFlush = null;
|
|
81
|
+
pendingWorkspaceIds = new Set();
|
|
82
|
+
for (const workspaceId of workspaceIds) {
|
|
83
|
+
if (disposed)
|
|
84
|
+
break;
|
|
85
|
+
try {
|
|
86
|
+
await deps.emitWorkspaceUpdateForWorkspaceId(workspaceId);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
deps.logger.error({ err: error, workspaceId }, "Failed to emit workspace update");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
flush?.resolve();
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Queue a workspace update and resolve once the batch holding it has flushed,
|
|
96
|
+
* so a caller that awaits still observes the emitted update.
|
|
97
|
+
*/
|
|
98
|
+
function scheduleWorkspaceUpdate(workspaceId) {
|
|
99
|
+
pendingWorkspaceIds.add(workspaceId);
|
|
100
|
+
if (!pendingWorkspaceFlush) {
|
|
101
|
+
let resolve;
|
|
102
|
+
const done = new Promise((settle) => {
|
|
103
|
+
resolve = settle;
|
|
104
|
+
});
|
|
105
|
+
const timer = setTimeout(() => void flushWorkspaceUpdates(), coalesceMs);
|
|
106
|
+
pendingWorkspaceFlush = { timer, done, resolve };
|
|
107
|
+
}
|
|
108
|
+
return pendingWorkspaceFlush.done;
|
|
109
|
+
}
|
|
67
110
|
function bufferOrEmit(sub, payload) {
|
|
68
111
|
if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) {
|
|
69
112
|
return;
|
|
@@ -158,7 +201,7 @@ export function createAgentUpdatesService(deps) {
|
|
|
158
201
|
});
|
|
159
202
|
return payload;
|
|
160
203
|
}
|
|
161
|
-
async function forwardLiveAgent(agent) {
|
|
204
|
+
async function forwardLiveAgent(agent, options) {
|
|
162
205
|
try {
|
|
163
206
|
const sub = subscription;
|
|
164
207
|
const payload = await deps.buildAgentPayload(agent);
|
|
@@ -196,7 +239,14 @@ export function createAgentUpdatesService(deps) {
|
|
|
196
239
|
// A lifecycle change updates exactly the agent's owning workspace, never
|
|
197
240
|
// every workspace sharing its cwd. Ownership is the agent's workspaceId.
|
|
198
241
|
if (payload.workspaceId) {
|
|
199
|
-
|
|
242
|
+
if (options?.immediate) {
|
|
243
|
+
// This flush covers any queued update for the same workspace.
|
|
244
|
+
pendingWorkspaceIds.delete(payload.workspaceId);
|
|
245
|
+
await deps.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
await scheduleWorkspaceUpdate(payload.workspaceId);
|
|
249
|
+
}
|
|
200
250
|
}
|
|
201
251
|
}
|
|
202
252
|
catch (error) {
|
|
@@ -205,6 +255,13 @@ export function createAgentUpdatesService(deps) {
|
|
|
205
255
|
}
|
|
206
256
|
function dispose() {
|
|
207
257
|
subscription = null;
|
|
258
|
+
disposed = true;
|
|
259
|
+
if (pendingWorkspaceFlush) {
|
|
260
|
+
clearTimeout(pendingWorkspaceFlush.timer);
|
|
261
|
+
pendingWorkspaceFlush.resolve();
|
|
262
|
+
pendingWorkspaceFlush = null;
|
|
263
|
+
pendingWorkspaceIds = new Set();
|
|
264
|
+
}
|
|
208
265
|
}
|
|
209
266
|
return {
|
|
210
267
|
beginSubscription,
|
|
@@ -66,8 +66,16 @@ export declare class ProviderCatalogSession {
|
|
|
66
66
|
private readonly providerUsageService;
|
|
67
67
|
private readonly logger;
|
|
68
68
|
private unsubscribeSnapshotEvents;
|
|
69
|
+
/**
|
|
70
|
+
* Snapshot keys (resolved cwds) this client has asked about. Background work
|
|
71
|
+
* warms a snapshot per agent worktree, and pushing every one of those to every
|
|
72
|
+
* client was ~145 unsolicited updates per 30s. A client only gets pushes for the
|
|
73
|
+
* global snapshot and for cwds it requested.
|
|
74
|
+
*/
|
|
75
|
+
private readonly requestedSnapshotCwds;
|
|
69
76
|
constructor(options: ProviderCatalogSessionOptions);
|
|
70
77
|
start(): void;
|
|
78
|
+
private noteRequestedCwd;
|
|
71
79
|
dispose(): void;
|
|
72
80
|
private downgradeModeIconsForClient;
|
|
73
81
|
private downgradeEntryModesForClient;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getErrorMessage } from "@hyperdrive.bot/fleet-protocol/error-utils";
|
|
2
|
-
import { isGlobalProviderSnapshotKey, } from "../../agent/provider-snapshot-manager.js";
|
|
2
|
+
import { isGlobalProviderSnapshotKey, resolveSnapshotCwd, } from "../../agent/provider-snapshot-manager.js";
|
|
3
3
|
import { expandTilde } from "../../../utils/path.js";
|
|
4
4
|
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
|
|
5
5
|
// other icon name is downgraded to "ShieldCheck" for those clients.
|
|
@@ -19,6 +19,13 @@ const LEGACY_MODE_ICONS = new Set([
|
|
|
19
19
|
export class ProviderCatalogSession {
|
|
20
20
|
constructor(options) {
|
|
21
21
|
this.unsubscribeSnapshotEvents = null;
|
|
22
|
+
/**
|
|
23
|
+
* Snapshot keys (resolved cwds) this client has asked about. Background work
|
|
24
|
+
* warms a snapshot per agent worktree, and pushing every one of those to every
|
|
25
|
+
* client was ~145 unsolicited updates per 30s. A client only gets pushes for the
|
|
26
|
+
* global snapshot and for cwds it requested.
|
|
27
|
+
*/
|
|
28
|
+
this.requestedSnapshotCwds = new Set();
|
|
22
29
|
this.host = options.host;
|
|
23
30
|
this.providerSnapshotManager = options.providerSnapshotManager;
|
|
24
31
|
this.providerUsageService = options.providerUsageService;
|
|
@@ -26,6 +33,9 @@ export class ProviderCatalogSession {
|
|
|
26
33
|
}
|
|
27
34
|
start() {
|
|
28
35
|
const handleProviderSnapshotChange = (entries, cwd) => {
|
|
36
|
+
if (!isGlobalProviderSnapshotKey(cwd) && !this.requestedSnapshotCwds.has(cwd)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
29
39
|
// COMPAT(providersSnapshot): keep provider visibility gating for older clients.
|
|
30
40
|
const visibleEntries = entries.filter((entry) => this.host.isProviderVisibleToClient(entry.provider));
|
|
31
41
|
const snapshotCwd = isGlobalProviderSnapshotKey(cwd) ? undefined : cwd;
|
|
@@ -43,6 +53,11 @@ export class ProviderCatalogSession {
|
|
|
43
53
|
this.providerSnapshotManager.off("change", handleProviderSnapshotChange);
|
|
44
54
|
};
|
|
45
55
|
}
|
|
56
|
+
noteRequestedCwd(cwd) {
|
|
57
|
+
if (cwd?.trim()) {
|
|
58
|
+
this.requestedSnapshotCwds.add(resolveSnapshotCwd(expandTilde(cwd)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
46
61
|
dispose() {
|
|
47
62
|
if (this.unsubscribeSnapshotEvents) {
|
|
48
63
|
this.unsubscribeSnapshotEvents();
|
|
@@ -78,6 +93,7 @@ export class ProviderCatalogSession {
|
|
|
78
93
|
}
|
|
79
94
|
async handleListProviderModelsRequest(msg) {
|
|
80
95
|
const cwd = resolveCatalogRequestCwd(msg.cwd);
|
|
96
|
+
this.noteRequestedCwd(cwd);
|
|
81
97
|
const fetchedAt = new Date().toISOString();
|
|
82
98
|
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
|
|
83
99
|
if (!entry) {
|
|
@@ -125,6 +141,7 @@ export class ProviderCatalogSession {
|
|
|
125
141
|
async handleListProviderModesRequest(msg) {
|
|
126
142
|
const fetchedAt = new Date().toISOString();
|
|
127
143
|
const cwd = resolveCatalogRequestCwd(msg.cwd);
|
|
144
|
+
this.noteRequestedCwd(cwd);
|
|
128
145
|
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
|
|
129
146
|
if (!entry) {
|
|
130
147
|
this.host.emit({
|
|
@@ -196,6 +213,7 @@ export class ProviderCatalogSession {
|
|
|
196
213
|
async handleListProviderFeaturesRequest(msg) {
|
|
197
214
|
const fetchedAt = new Date().toISOString();
|
|
198
215
|
try {
|
|
216
|
+
this.noteRequestedCwd(msg.draftConfig.cwd);
|
|
199
217
|
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
|
|
200
218
|
const features = await this.host.listDraftFeatures(sessionConfig);
|
|
201
219
|
this.host.emit({
|
|
@@ -250,6 +268,7 @@ export class ProviderCatalogSession {
|
|
|
250
268
|
}
|
|
251
269
|
}
|
|
252
270
|
async handleGetProvidersSnapshotRequest(msg) {
|
|
271
|
+
this.noteRequestedCwd(msg.cwd);
|
|
253
272
|
// COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow.
|
|
254
273
|
const entries = this.providerSnapshotManager
|
|
255
274
|
.getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined)
|
|
@@ -286,6 +305,7 @@ export class ProviderCatalogSession {
|
|
|
286
305
|
}
|
|
287
306
|
async handleRefreshProvidersSnapshotRequest(msg) {
|
|
288
307
|
if (msg.cwd) {
|
|
308
|
+
this.noteRequestedCwd(msg.cwd);
|
|
289
309
|
await this.providerSnapshotManager.refreshSnapshotForCwd({
|
|
290
310
|
cwd: expandTilde(msg.cwd),
|
|
291
311
|
providers: msg.providers,
|
|
@@ -13,6 +13,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
|
|
|
13
13
|
import { AgentManager } from "./agent/agent-manager.js";
|
|
14
14
|
import type { WorkflowManager } from "./workflow/workflow-manager.js";
|
|
15
15
|
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
|
16
|
+
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
|
16
17
|
import type { AgentStorage } from "./agent/agent-storage.js";
|
|
17
18
|
import { type PersistedWorkspaceRecord, type ProjectRegistry, type WorkspaceRegistry } from "./workspace-registry.js";
|
|
18
19
|
import type { CohortRecorder } from "./ingestion/apply.js";
|
|
@@ -167,6 +168,15 @@ export type SessionLifecycleIntent = {
|
|
|
167
168
|
requestId: string;
|
|
168
169
|
reason: string;
|
|
169
170
|
};
|
|
171
|
+
/**
|
|
172
|
+
* The stored records a workspace descriptor for `workspaceIds` can depend on:
|
|
173
|
+
* records owned by one of those workspaces, running delegated records (they roll
|
|
174
|
+
* up to their delegation root, see WorkspaceDirectory), and the ancestor chain of
|
|
175
|
+
* every delegated record or live agent so the root can be resolved.
|
|
176
|
+
*/
|
|
177
|
+
export declare function selectRecordsForWorkspaces(records: readonly StoredAgentRecord[], liveAgents: ReadonlyArray<{
|
|
178
|
+
labels?: Record<string, unknown> | null;
|
|
179
|
+
}>, workspaceIds: ReadonlySet<string>): StoredAgentRecord[];
|
|
170
180
|
/**
|
|
171
181
|
* Session represents a single connected client session.
|
|
172
182
|
* It owns all state management, orchestration logic, and message processing.
|
|
@@ -505,6 +515,12 @@ export declare class Session {
|
|
|
505
515
|
/** Read one background shell's captured output (tailed and redacted). */
|
|
506
516
|
private handleBackgroundTaskOutputRequest;
|
|
507
517
|
private handleDismissBackgroundTaskRequest;
|
|
518
|
+
/**
|
|
519
|
+
* Persist the archive and emit the archived agent, then hand back the rest
|
|
520
|
+
* (process teardown, then the workspace delta) as `background` so the caller
|
|
521
|
+
* can reply first. Stopping a busy agent and rebuilding its workspace used to
|
|
522
|
+
* sit in front of the reply.
|
|
523
|
+
*/
|
|
508
524
|
private archiveAgentForClose;
|
|
509
525
|
private handleDetachAgentRequest;
|
|
510
526
|
private handleCloseItemsRequest;
|