agent-relay-server 0.11.4 → 0.11.5
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/package.json +3 -2
- package/public/index.html +19 -11
- package/src/db.ts +22 -0
- package/src/maintenance.ts +100 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.5",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,10 @@
|
|
|
32
32
|
"CONTRIBUTING.md"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"agent-relay-sdk": "0.2.
|
|
35
|
+
"agent-relay-sdk": "0.2.4"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
|
+
"prepack": "cd dashboard && npx vite build >&2",
|
|
38
39
|
"postinstall": "node scripts/install-bin-shim.cjs",
|
|
39
40
|
"start": "bun run src/index.ts",
|
|
40
41
|
"dev": "bun --watch run src/index.ts",
|
package/public/index.html
CHANGED
|
@@ -123101,7 +123101,9 @@ function WorkspaceActions({ workspace, expanded, onToggleDetails }) {
|
|
|
123101
123101
|
const terminal = workspace.status === "cleaned" || workspace.status === "merged" || workspace.status === "abandoned";
|
|
123102
123102
|
const disabled = terminal || workspace.status === "cleanup_requested";
|
|
123103
123103
|
const openPath = workspace.worktreePath || workspace.sourceCwd || workspace.repoRoot;
|
|
123104
|
-
const
|
|
123104
|
+
const gitState = useRelayStore((s) => s.workspaceGitState[workspace.id]);
|
|
123105
|
+
const landed = !!gitState && gitState.available !== false && gitState.landed === true;
|
|
123106
|
+
const mergeable = workspace.mode === "isolated" && Boolean(workspace.worktreePath) && MERGEABLE_STATUSES.has(workspace.status) && !landed;
|
|
123105
123107
|
async function copyPath() {
|
|
123106
123108
|
await navigator.clipboard?.writeText(openPath);
|
|
123107
123109
|
}
|
|
@@ -123162,7 +123164,7 @@ function WorkspaceActions({ workspace, expanded, onToggleDetails }) {
|
|
|
123162
123164
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
|
|
123163
123165
|
size: "icon-sm",
|
|
123164
123166
|
variant: "default",
|
|
123165
|
-
title: "Merge & land work",
|
|
123167
|
+
title: landed ? "Already merged into base — nothing to land" : "Merge & land work",
|
|
123166
123168
|
disabled: !mergeable,
|
|
123167
123169
|
onClick: () => void merge(),
|
|
123168
123170
|
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GitMerge, { className: "h-3.5 w-3.5" })
|
|
@@ -123185,10 +123187,10 @@ function WorkspaceActions({ workspace, expanded, onToggleDetails }) {
|
|
|
123185
123187
|
]
|
|
123186
123188
|
});
|
|
123187
123189
|
}
|
|
123188
|
-
function MergePreviewHint({ workspace,
|
|
123190
|
+
function MergePreviewHint({ workspace, unmerged }) {
|
|
123189
123191
|
const preview = useRelayStore((s) => s.workspaceMergePreview[workspace.id]);
|
|
123190
123192
|
const fetchWorkspaceMergePreview = useRelayStore((s) => s.fetchWorkspaceMergePreview);
|
|
123191
|
-
const eligible =
|
|
123193
|
+
const eligible = unmerged > 0 && MERGEABLE_STATUSES.has(workspace.status);
|
|
123192
123194
|
(0, import_react.useEffect)(() => {
|
|
123193
123195
|
if (eligible && preview === void 0) fetchWorkspaceMergePreview(workspace.id);
|
|
123194
123196
|
}, [
|
|
@@ -123251,9 +123253,11 @@ function GitState({ workspace }) {
|
|
|
123251
123253
|
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "git error" }), refresh]
|
|
123252
123254
|
});
|
|
123253
123255
|
const ahead = gitState.ahead ?? 0;
|
|
123256
|
+
const landed = gitState.landed === true;
|
|
123257
|
+
const unmerged = landed ? 0 : gitState.unmergedAhead ?? ahead;
|
|
123254
123258
|
const behind = gitState.behind ?? 0;
|
|
123255
123259
|
const dirty = gitState.dirtyCount ?? 0;
|
|
123256
|
-
const clean =
|
|
123260
|
+
const clean = unmerged === 0 && dirty === 0 && !landed;
|
|
123257
123261
|
const commit = gitState.lastCommit;
|
|
123258
123262
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
123259
123263
|
className: "space-y-0.5",
|
|
@@ -123263,10 +123267,14 @@ function GitState({ workspace }) {
|
|
|
123263
123267
|
className: "text-muted-foreground",
|
|
123264
123268
|
children: "no unmerged work"
|
|
123265
123269
|
}) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
123266
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
123267
|
-
className:
|
|
123268
|
-
title:
|
|
123269
|
-
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
123270
|
+
landed ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
123271
|
+
className: "flex items-center gap-0.5 text-emerald-400",
|
|
123272
|
+
title: `Work already landed in ${gitState.baseRef || "base"} via squash/cherry-pick — safe to clean up`,
|
|
123273
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Check, { className: "h-3 w-3" }), "landed"]
|
|
123274
|
+
}) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
123275
|
+
className: unmerged > 0 ? "flex items-center text-emerald-400" : "flex items-center text-muted-foreground",
|
|
123276
|
+
title: ahead !== unmerged ? `${unmerged} unmerged commit(s) (${ahead} ahead of base)` : `${unmerged} commit(s) ahead of base`,
|
|
123277
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ArrowUp, { className: "h-3 w-3" }), unmerged]
|
|
123270
123278
|
}),
|
|
123271
123279
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
123272
123280
|
className: behind > 0 ? "flex items-center text-amber-400" : "flex items-center text-muted-foreground",
|
|
@@ -123278,9 +123286,9 @@ function GitState({ workspace }) {
|
|
|
123278
123286
|
title: `${dirty} uncommitted change(s)`,
|
|
123279
123287
|
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(FilePen, { className: "h-3 w-3" }), dirty]
|
|
123280
123288
|
}),
|
|
123281
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(MergePreviewHint, {
|
|
123289
|
+
!landed && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MergePreviewHint, {
|
|
123282
123290
|
workspace,
|
|
123283
|
-
|
|
123291
|
+
unmerged
|
|
123284
123292
|
})
|
|
123285
123293
|
] }), refresh]
|
|
123286
123294
|
}), commit && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
package/src/db.ts
CHANGED
|
@@ -5065,6 +5065,28 @@ export function deleteWorkspace(id: string): boolean {
|
|
|
5065
5065
|
return db.prepare("DELETE FROM workspaces WHERE id = ?").run(id).changes > 0;
|
|
5066
5066
|
}
|
|
5067
5067
|
|
|
5068
|
+
// Shared-mode rows are pure occupancy markers (no worktree on disk) that only
|
|
5069
|
+
// mean something while their owner is online. Deletion is normally driven by
|
|
5070
|
+
// the reaper's onAgentDisappeared hook, but agents also leave via clean
|
|
5071
|
+
// SessionEnd (setStatus offline) and via pruneOfflineAgents (raw agent delete),
|
|
5072
|
+
// neither of which touches workspaces — so orphaned shared rows accumulate and
|
|
5073
|
+
// bloat the workspace panel. This sweep is the catch-all: drop any non-cleaned
|
|
5074
|
+
// shared row whose owner is missing or offline, regardless of how it leaked.
|
|
5075
|
+
export function pruneOrphanedSharedWorkspaces(): string[] {
|
|
5076
|
+
return db.transaction(() => {
|
|
5077
|
+
const orphanCondition = `
|
|
5078
|
+
mode = 'shared' AND status != 'cleaned' AND (
|
|
5079
|
+
owner_agent_id IS NULL
|
|
5080
|
+
OR owner_agent_id NOT IN (SELECT id FROM agents)
|
|
5081
|
+
OR owner_agent_id IN (SELECT id FROM agents WHERE status = 'offline')
|
|
5082
|
+
)`;
|
|
5083
|
+
const rows = db.prepare(`SELECT id FROM workspaces WHERE ${orphanCondition}`).all() as Array<{ id: string }>;
|
|
5084
|
+
if (!rows.length) return [];
|
|
5085
|
+
db.prepare(`DELETE FROM workspaces WHERE ${orphanCondition}`).run();
|
|
5086
|
+
return rows.map((r) => r.id);
|
|
5087
|
+
})();
|
|
5088
|
+
}
|
|
5089
|
+
|
|
5068
5090
|
export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metadata: Record<string, unknown> = {}): WorkspaceRecord | null {
|
|
5069
5091
|
const existing = getWorkspace(id);
|
|
5070
5092
|
if (!existing) return null;
|
package/src/maintenance.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { getArtifactStorage } from "./artifact-storage";
|
|
3
3
|
import { expireStaleBusAgents } from "./bus";
|
|
4
4
|
import { pruneOutbox } from "./bus-outbox";
|
|
5
|
-
import { expireCommands } from "./commands-db";
|
|
5
|
+
import { createCommand, expireCommands } from "./commands-db";
|
|
6
6
|
import { DAY_MS, OFFLINE_PRUNE_MS, REAP_INTERVAL_MS, STALE_TTL_MS } from "./config";
|
|
7
7
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
8
8
|
import {
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
listWorkspaces,
|
|
15
15
|
pruneOfflineAgents,
|
|
16
16
|
pruneOldMessages,
|
|
17
|
+
deleteWorkspace,
|
|
18
|
+
pruneOrphanedSharedWorkspaces,
|
|
17
19
|
reapStaleAgents,
|
|
18
20
|
reapStaleOrchestrators,
|
|
19
21
|
releaseExpiredClaims,
|
|
@@ -44,9 +46,13 @@ const SCHEDULER_TICK_MS = 10_000;
|
|
|
44
46
|
const OUTBOX_RETENTION_MS = Number(process.env.AGENT_RELAY_OUTBOX_RETENTION_MS) || 60 * 60 * 1000;
|
|
45
47
|
const TOKEN_RECORD_RETENTION_SECONDS = Number(process.env.AGENT_RELAY_TOKEN_RECORD_RETENTION_SECONDS) || 7 * 24 * 60 * 60;
|
|
46
48
|
const CONFLICT_SCAN_INTERVAL_MS = Number(process.env.AGENT_RELAY_CONFLICT_SCAN_INTERVAL_MS) || 2 * 60 * 1000;
|
|
49
|
+
const WORKSPACE_RETENTION_MS = Number(process.env.AGENT_RELAY_WORKSPACE_RETENTION_MS) || DAY_MS;
|
|
50
|
+
const WORKSPACE_REVIEW_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_REVIEW_TTL_MS) || 3 * DAY_MS;
|
|
51
|
+
const WORKSPACE_GC_INTERVAL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_GC_INTERVAL_MS) || 60 * 60 * 1000;
|
|
47
52
|
// Live statuses worth scanning. Terminal (cleaned/merged/abandoned) and
|
|
48
53
|
// in-flight (cleanup_requested) states are skipped.
|
|
49
54
|
const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
|
|
55
|
+
const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
|
|
50
56
|
|
|
51
57
|
interface MaintenanceJobDefinition {
|
|
52
58
|
id: string;
|
|
@@ -279,6 +285,17 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
279
285
|
return { prunedTokenJtis };
|
|
280
286
|
},
|
|
281
287
|
},
|
|
288
|
+
{
|
|
289
|
+
id: "workspace-orphan-sweep",
|
|
290
|
+
title: "Workspace orphan sweep",
|
|
291
|
+
description: "Delete shared-mode workspace occupancy rows whose owner agent is offline or gone.",
|
|
292
|
+
intervalMs: REAP_INTERVAL_MS,
|
|
293
|
+
runOnStart: true,
|
|
294
|
+
handler() {
|
|
295
|
+
const prunedSharedWorkspaceIds = pruneOrphanedSharedWorkspaces();
|
|
296
|
+
return { prunedSharedWorkspaceIds };
|
|
297
|
+
},
|
|
298
|
+
},
|
|
282
299
|
{
|
|
283
300
|
id: "workspace-conflict-scan",
|
|
284
301
|
title: "Workspace conflict scan",
|
|
@@ -288,6 +305,15 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
288
305
|
timeoutMs: 60 * 1000,
|
|
289
306
|
handler: scanWorkspaceConflicts,
|
|
290
307
|
},
|
|
308
|
+
{
|
|
309
|
+
id: "workspace-gc",
|
|
310
|
+
title: "Workspace GC",
|
|
311
|
+
description: "Prune terminal workspace rows past retention, auto-abandon stale review_requested worktrees, and trigger git worktree prune on orchestrators.",
|
|
312
|
+
intervalMs: WORKSPACE_GC_INTERVAL_MS,
|
|
313
|
+
runOnStart: false,
|
|
314
|
+
timeoutMs: 60 * 1000,
|
|
315
|
+
handler: workspaceGC,
|
|
316
|
+
},
|
|
291
317
|
];
|
|
292
318
|
|
|
293
319
|
function workspacePathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
|
|
@@ -387,6 +413,79 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
387
413
|
return { scanned: candidates.length, flagged, cleared, notifiedStewards };
|
|
388
414
|
}
|
|
389
415
|
|
|
416
|
+
async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
417
|
+
const now = Date.now();
|
|
418
|
+
const cutoff = now - WORKSPACE_RETENTION_MS;
|
|
419
|
+
const reviewCutoff = now - WORKSPACE_REVIEW_TTL_MS;
|
|
420
|
+
|
|
421
|
+
// 1. Prune terminal rows past retention
|
|
422
|
+
const all = listWorkspaces();
|
|
423
|
+
const terminalIds: string[] = [];
|
|
424
|
+
for (const ws of all) {
|
|
425
|
+
if (TERMINAL_WORKSPACE_STATUSES.has(ws.status) && ws.updatedAt < cutoff) {
|
|
426
|
+
deleteWorkspace(ws.id);
|
|
427
|
+
terminalIds.push(ws.id);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 2. Auto-abandon stale review_requested worktrees
|
|
432
|
+
const abandonedIds: string[] = [];
|
|
433
|
+
const notifiedStewards: string[] = [];
|
|
434
|
+
for (const ws of all) {
|
|
435
|
+
if (ws.status === "review_requested" && ws.updatedAt < reviewCutoff) {
|
|
436
|
+
updateWorkspaceStatus(ws.id, "abandoned", { autoAbandoned: true, abandonedReason: "review_requested TTL exceeded", abandonedAt: now });
|
|
437
|
+
abandonedIds.push(ws.id);
|
|
438
|
+
if (ws.stewardAgentId) {
|
|
439
|
+
try {
|
|
440
|
+
const msg = sendMessage({
|
|
441
|
+
from: "system",
|
|
442
|
+
to: ws.stewardAgentId,
|
|
443
|
+
kind: "system",
|
|
444
|
+
subject: "Workspace auto-abandoned",
|
|
445
|
+
body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
|
|
446
|
+
payload: { kind: "workspace.auto-abandoned", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch },
|
|
447
|
+
});
|
|
448
|
+
emitNewMessage(msg);
|
|
449
|
+
notifiedStewards.push(ws.stewardAgentId);
|
|
450
|
+
} catch {
|
|
451
|
+
// Steward gone — activity event is enough.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
createActivityEvent({
|
|
455
|
+
clientId: `workspace-gc-abandon-${ws.id}-${now}`,
|
|
456
|
+
kind: "state",
|
|
457
|
+
title: "Workspace auto-abandoned",
|
|
458
|
+
body: `${ws.branch ?? ws.id} in ${ws.repoRoot} — review_requested for ${Math.round((now - ws.updatedAt) / DAY_MS)}d`,
|
|
459
|
+
meta: ws.branch ?? ws.id,
|
|
460
|
+
icon: "ti-clock-x",
|
|
461
|
+
view: "orchestrators",
|
|
462
|
+
metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: ws.id },
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 3. Trigger git worktree prune on orchestrators that own isolated workspaces
|
|
468
|
+
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.agentId);
|
|
469
|
+
const reposWithWorkspaces = new Set(
|
|
470
|
+
all.filter((ws) => ws.mode === "isolated" && Boolean(ws.worktreePath)).map((ws) => ws.repoRoot),
|
|
471
|
+
);
|
|
472
|
+
const pruneCommands: string[] = [];
|
|
473
|
+
for (const repoRoot of reposWithWorkspaces) {
|
|
474
|
+
const orch = orchestrators.find((candidate) => workspacePathWithinBase(repoRoot, candidate.baseDir));
|
|
475
|
+
if (!orch?.agentId) continue;
|
|
476
|
+
const command = createCommand({
|
|
477
|
+
type: "workspace.prune",
|
|
478
|
+
source: "system",
|
|
479
|
+
target: orch.agentId,
|
|
480
|
+
params: { repoRoot, requestedBy: "workspace-gc", requestedAt: now },
|
|
481
|
+
});
|
|
482
|
+
emitCommand(command);
|
|
483
|
+
pruneCommands.push(command.id);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return { prunedTerminal: terminalIds, autoAbandoned: abandonedIds, notifiedStewards, pruneCommands };
|
|
487
|
+
}
|
|
488
|
+
|
|
390
489
|
let timer: Timer | null = null;
|
|
391
490
|
|
|
392
491
|
export function startMaintenanceScheduler(): void {
|