agent-relay-server 0.33.0 → 0.34.0
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 +1 -1
- package/runner/src/adapter.ts +21 -4
- package/src/channel-target.ts +24 -0
- package/src/db/activity.ts +194 -0
- package/src/db/agent-search.ts +174 -0
- package/src/db/agents.ts +551 -0
- package/src/db/artifacts.ts +342 -0
- package/src/db/channels.ts +576 -0
- package/src/db/connection.ts +71 -0
- package/src/db/delivery.ts +395 -0
- package/src/db/inbox.ts +249 -0
- package/src/db/index.ts +23 -0
- package/src/db/integrations.ts +339 -0
- package/src/db/mappers.ts +397 -0
- package/src/db/merge-lease.ts +160 -0
- package/src/db/message-reads.ts +304 -0
- package/src/db/messages.ts +434 -0
- package/src/db/migrations.ts +431 -0
- package/src/db/orchestrators.ts +358 -0
- package/src/db/pairs.ts +324 -0
- package/src/db/schema.ts +758 -0
- package/src/db/stats.ts +337 -0
- package/src/db/tasks.ts +407 -0
- package/src/db/workspaces.ts +440 -0
- package/src/db.ts +4 -5721
- package/src/routes/integrations.ts +6 -8
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
|
|
4
|
+
import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
|
|
5
|
+
import { parseJson } from "../utils";
|
|
6
|
+
import { isLiveIsolatedWorkspace } from "../workspace-phase";
|
|
7
|
+
import {
|
|
8
|
+
CONTRACT_REQUIREMENTS,
|
|
9
|
+
contractCompatibility,
|
|
10
|
+
parseRuntimeCapabilities,
|
|
11
|
+
parseRuntimeContracts,
|
|
12
|
+
parseRuntimePackage,
|
|
13
|
+
type RuntimeContracts,
|
|
14
|
+
} from "../contracts";
|
|
15
|
+
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
|
|
16
|
+
import { matchAgents } from "../agent-ref";
|
|
17
|
+
import { getDb } from "./connection.ts";
|
|
18
|
+
import type {
|
|
19
|
+
AgentCard,
|
|
20
|
+
ActivityEvent,
|
|
21
|
+
ActivityEventInput,
|
|
22
|
+
AgentKind,
|
|
23
|
+
AgentSessionGuard,
|
|
24
|
+
Artifact,
|
|
25
|
+
ArtifactBlob,
|
|
26
|
+
ArtifactKind,
|
|
27
|
+
ArtifactLink,
|
|
28
|
+
ArtifactSensitivity,
|
|
29
|
+
ArtifactVisibility,
|
|
30
|
+
AttachmentRef,
|
|
31
|
+
ChannelBinding,
|
|
32
|
+
ChannelBindingMode,
|
|
33
|
+
ChannelRouteTarget,
|
|
34
|
+
ChatHistoryImport,
|
|
35
|
+
ChatHistoryImportEntry,
|
|
36
|
+
ChannelSummary,
|
|
37
|
+
ChannelTargetHealth,
|
|
38
|
+
CreatePairInput,
|
|
39
|
+
HealthCheck,
|
|
40
|
+
HealthReport,
|
|
41
|
+
ManagedAgent,
|
|
42
|
+
ManagedSessionExitDiagnostics,
|
|
43
|
+
Message,
|
|
44
|
+
MessageDeliveryAttempt,
|
|
45
|
+
MessageDeliveryStatus,
|
|
46
|
+
Orchestrator,
|
|
47
|
+
OrchestratorHealth,
|
|
48
|
+
OrchestratorRuntimeInput,
|
|
49
|
+
OrchestratorStatus,
|
|
50
|
+
OrchestratorUpgradeState,
|
|
51
|
+
PairActionInput,
|
|
52
|
+
PairMessageInput,
|
|
53
|
+
PairSession,
|
|
54
|
+
PairStatus,
|
|
55
|
+
RegisterAgentInput,
|
|
56
|
+
ReplyObligation,
|
|
57
|
+
RegisterOrchestratorInput,
|
|
58
|
+
SendMessageInput,
|
|
59
|
+
PollQuery,
|
|
60
|
+
SpawnApprovalMode,
|
|
61
|
+
SpawnProvider,
|
|
62
|
+
Task,
|
|
63
|
+
TaskEvent,
|
|
64
|
+
TaskSeverity,
|
|
65
|
+
TaskStatus,
|
|
66
|
+
IntegrationEventInput,
|
|
67
|
+
IntegrationSummary,
|
|
68
|
+
IntegrationTaskStats,
|
|
69
|
+
InboxDraft,
|
|
70
|
+
InboxState,
|
|
71
|
+
InboxThreadState,
|
|
72
|
+
ContextSnapshot,
|
|
73
|
+
ContextState,
|
|
74
|
+
ProviderCapabilities,
|
|
75
|
+
TaskStatusInput,
|
|
76
|
+
WorkspaceMetadata,
|
|
77
|
+
WorkspaceRecord,
|
|
78
|
+
WorkspaceStatus,
|
|
79
|
+
} from "../types";
|
|
80
|
+
|
|
81
|
+
export function rowToWorkspace(row: any): WorkspaceRecord {
|
|
82
|
+
return {
|
|
83
|
+
id: row.id,
|
|
84
|
+
repoRoot: row.repo_root,
|
|
85
|
+
sourceCwd: row.source_cwd,
|
|
86
|
+
worktreePath: row.worktree_path,
|
|
87
|
+
branch: row.branch ?? undefined,
|
|
88
|
+
baseRef: row.base_ref ?? undefined,
|
|
89
|
+
baseSha: row.base_sha ?? undefined,
|
|
90
|
+
mode: row.mode,
|
|
91
|
+
requestedMode: row.requested_mode ?? undefined,
|
|
92
|
+
status: row.status,
|
|
93
|
+
ownerAgentId: row.owner_agent_id ?? undefined,
|
|
94
|
+
ownerPolicyName: row.owner_policy_name ?? undefined,
|
|
95
|
+
ownerAutomationRunId: row.owner_automation_run_id ?? undefined,
|
|
96
|
+
stewardAgentId: row.steward_agent_id ?? undefined,
|
|
97
|
+
metadata: parseJson<Record<string, unknown>>(row.metadata, {}),
|
|
98
|
+
createdAt: row.created_at,
|
|
99
|
+
updatedAt: row.updated_at,
|
|
100
|
+
readyAt: row.ready_at ?? undefined,
|
|
101
|
+
cleanedAt: row.cleaned_at ?? undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
export function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord | null {
|
|
107
|
+
const workspace = agent.workspace;
|
|
108
|
+
if (!workspace) return null;
|
|
109
|
+
if (workspace.mode === "isolated" && workspace.id && workspace.repoRoot && workspace.worktreePath) {
|
|
110
|
+
// A live agent re-registers its workspace on every heartbeat. That must not
|
|
111
|
+
// clobber progress the workspace has made past the live editing state
|
|
112
|
+
// (a merge that opened a PR, ready/review/conflict, terminal states) nor
|
|
113
|
+
// wipe accumulated metadata such as the merge result / PR link. Only the
|
|
114
|
+
// "active" live state is refreshable from registration; everything else is
|
|
115
|
+
// preserved and metadata is merged, not replaced.
|
|
116
|
+
const existing = getWorkspace(workspace.id);
|
|
117
|
+
const preserveStatus = existing != null && existing.status !== "active";
|
|
118
|
+
// The branch and base change ONLY via the relay's own land-and-continue recycle
|
|
119
|
+
// (setWorkspaceBranch repoints to `<branch>-N` and bumps base_sha; base_ref is fixed
|
|
120
|
+
// at spawn and never re-targeted). The runner keeps re-reporting its spawn-time
|
|
121
|
+
// branch/base on every heartbeat, and the recycle returns status to "active" — so
|
|
122
|
+
// without this the next heartbeat clobbers the repoint back to the original branch,
|
|
123
|
+
// and the next land targets a deleted branch and strands the work (vent #62 follow-up).
|
|
124
|
+
// baseRef needs the same pin: a heartbeat carrying a stale/wrong base (e.g. a recycled
|
|
125
|
+
// `-N` branch when the agent was spawned from inside a managed worktree) would otherwise
|
|
126
|
+
// overwrite the true base, and the next land advances a local-only branch that never
|
|
127
|
+
// reaches origin/main (#285). Trust the existing row's branch/base over registration;
|
|
128
|
+
// only a brand-new row takes the runner's values.
|
|
129
|
+
return upsertWorkspace({
|
|
130
|
+
id: workspace.id,
|
|
131
|
+
repoRoot: workspace.repoRoot,
|
|
132
|
+
sourceCwd: workspace.sourceCwd ?? agent.cwd,
|
|
133
|
+
worktreePath: workspace.worktreePath,
|
|
134
|
+
branch: existing?.branch ?? workspace.branch,
|
|
135
|
+
baseRef: existing?.baseRef ?? workspace.baseRef,
|
|
136
|
+
baseSha: existing?.baseSha ?? workspace.baseSha,
|
|
137
|
+
mode: workspace.mode,
|
|
138
|
+
requestedMode: workspace.requestedMode,
|
|
139
|
+
status: preserveStatus ? existing!.status : (workspace.status ?? "active"),
|
|
140
|
+
ownerAgentId: agent.agentId || undefined,
|
|
141
|
+
ownerPolicyName: agent.policyName,
|
|
142
|
+
ownerAutomationRunId: agent.automationRunId,
|
|
143
|
+
metadata: { ...(existing?.metadata ?? {}), provider: agent.provider, label: agent.label, sessionName: agent.sessionName, tmuxSession: agent.tmuxSession },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Shared-mode occupancy: one row per agent sharing a git repo, with no
|
|
147
|
+
// worktree. Records true repo occupancy in the Workspaces view (informational
|
|
148
|
+
// only — never a cleanup/merge target). Skip non-git launches.
|
|
149
|
+
const sharedRepoRoot = workspace.mode === "shared" ? (workspace.repoRoot ?? workspace.probe?.repoRoot) : undefined;
|
|
150
|
+
if (sharedRepoRoot && agent.agentId) {
|
|
151
|
+
return upsertWorkspace({
|
|
152
|
+
id: `shared-${agent.agentId}`,
|
|
153
|
+
repoRoot: sharedRepoRoot,
|
|
154
|
+
sourceCwd: workspace.sourceCwd ?? agent.cwd,
|
|
155
|
+
worktreePath: "",
|
|
156
|
+
mode: "shared",
|
|
157
|
+
requestedMode: workspace.requestedMode,
|
|
158
|
+
status: "active",
|
|
159
|
+
ownerAgentId: agent.agentId,
|
|
160
|
+
ownerPolicyName: agent.policyName,
|
|
161
|
+
ownerAutomationRunId: agent.automationRunId,
|
|
162
|
+
metadata: { occupancy: true, provider: agent.provider, label: agent.label, sessionName: agent.sessionName, tmuxSession: agent.tmuxSession },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function upsertWorkspace(input: Omit<WorkspaceRecord, "createdAt" | "updatedAt"> & Partial<Pick<WorkspaceRecord, "createdAt" | "updatedAt">>): WorkspaceRecord {
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
getDb().query(`
|
|
171
|
+
INSERT INTO workspaces (id, repo_root, source_cwd, worktree_path, branch, base_ref, base_sha, mode, requested_mode, status, owner_agent_id, owner_policy_name, owner_automation_run_id, steward_agent_id, metadata, created_at, updated_at, ready_at, cleaned_at)
|
|
172
|
+
VALUES ($id, $repoRoot, $sourceCwd, $worktreePath, $branch, $baseRef, $baseSha, $mode, $requestedMode, $status, $ownerAgentId, $ownerPolicyName, $ownerAutomationRunId, $stewardAgentId, $metadata, $createdAt, $updatedAt, $readyAt, $cleanedAt)
|
|
173
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
174
|
+
repo_root = excluded.repo_root,
|
|
175
|
+
source_cwd = excluded.source_cwd,
|
|
176
|
+
worktree_path = excluded.worktree_path,
|
|
177
|
+
branch = excluded.branch,
|
|
178
|
+
base_ref = excluded.base_ref,
|
|
179
|
+
base_sha = excluded.base_sha,
|
|
180
|
+
mode = excluded.mode,
|
|
181
|
+
requested_mode = excluded.requested_mode,
|
|
182
|
+
status = excluded.status,
|
|
183
|
+
owner_agent_id = coalesce(excluded.owner_agent_id, workspaces.owner_agent_id),
|
|
184
|
+
owner_policy_name = coalesce(excluded.owner_policy_name, workspaces.owner_policy_name),
|
|
185
|
+
owner_automation_run_id = coalesce(excluded.owner_automation_run_id, workspaces.owner_automation_run_id),
|
|
186
|
+
steward_agent_id = coalesce(excluded.steward_agent_id, workspaces.steward_agent_id),
|
|
187
|
+
metadata = excluded.metadata,
|
|
188
|
+
updated_at = excluded.updated_at,
|
|
189
|
+
ready_at = coalesce(excluded.ready_at, workspaces.ready_at),
|
|
190
|
+
cleaned_at = coalesce(excluded.cleaned_at, workspaces.cleaned_at)
|
|
191
|
+
`).run({
|
|
192
|
+
$id: input.id,
|
|
193
|
+
$repoRoot: input.repoRoot,
|
|
194
|
+
$sourceCwd: input.sourceCwd,
|
|
195
|
+
$worktreePath: input.worktreePath,
|
|
196
|
+
$branch: input.branch ?? null,
|
|
197
|
+
$baseRef: input.baseRef ?? null,
|
|
198
|
+
$baseSha: input.baseSha ?? null,
|
|
199
|
+
$mode: input.mode,
|
|
200
|
+
$requestedMode: input.requestedMode ?? null,
|
|
201
|
+
$status: input.status,
|
|
202
|
+
$ownerAgentId: input.ownerAgentId ?? null,
|
|
203
|
+
$ownerPolicyName: input.ownerPolicyName ?? null,
|
|
204
|
+
$ownerAutomationRunId: input.ownerAutomationRunId ?? null,
|
|
205
|
+
$stewardAgentId: input.stewardAgentId ?? null,
|
|
206
|
+
$metadata: JSON.stringify(input.metadata ?? {}),
|
|
207
|
+
$createdAt: input.createdAt ?? now,
|
|
208
|
+
$updatedAt: input.updatedAt ?? now,
|
|
209
|
+
$readyAt: input.readyAt ?? null,
|
|
210
|
+
$cleanedAt: input.cleanedAt ?? null,
|
|
211
|
+
});
|
|
212
|
+
electWorkspaceStewards(input.repoRoot);
|
|
213
|
+
return getWorkspace(input.id)!;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function getWorkspace(id: string): WorkspaceRecord | null {
|
|
217
|
+
const row = getDb().query("SELECT * FROM workspaces WHERE id = ?").get(id) as any;
|
|
218
|
+
return row ? rowToWorkspace(row) : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function listWorkspaces(filter: { repoRoot?: string; ownerAgentId?: string; status?: WorkspaceStatus } = {}): WorkspaceRecord[] {
|
|
222
|
+
const where: string[] = [];
|
|
223
|
+
const params: string[] = [];
|
|
224
|
+
if (filter.repoRoot) { where.push("repo_root = ?"); params.push(filter.repoRoot); }
|
|
225
|
+
if (filter.ownerAgentId) { where.push("owner_agent_id = ?"); params.push(filter.ownerAgentId); }
|
|
226
|
+
if (filter.status) { where.push("status = ?"); params.push(filter.status); }
|
|
227
|
+
const sql = `SELECT * FROM workspaces${where.length ? " WHERE " + where.join(" AND ") : ""} ORDER BY updated_at DESC`;
|
|
228
|
+
return (getDb().query(sql).all(...params) as any[]).map(rowToWorkspace);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function deleteWorkspace(id: string): boolean {
|
|
232
|
+
return getDb().query("DELETE FROM workspaces WHERE id = ?").run(id).changes > 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The agent's current branch worktree: its most recent live (non-terminal) isolated
|
|
236
|
+
// workspace, or undefined. SINGLE HOME for the agent→workspace link — the MCP
|
|
237
|
+
// owner-resolver and the #236 branch-state badge both go through here. listWorkspaces
|
|
238
|
+
// is ORDER BY updated_at DESC, so `.find` returns the most recently active one.
|
|
239
|
+
export function ownedIsolatedWorkspace(agentId: string): WorkspaceRecord | undefined {
|
|
240
|
+
return listWorkspaces({ ownerAgentId: agentId }).find(isLiveIsolatedWorkspace);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Shared-mode rows are pure occupancy markers (no worktree on disk) that only
|
|
244
|
+
// mean something while their owner is online. Deletion is normally driven by
|
|
245
|
+
// the reaper's onAgentDisappeared hook, but agents also leave via clean
|
|
246
|
+
// SessionEnd (setStatus offline) and via pruneOfflineAgents (raw agent delete),
|
|
247
|
+
// neither of which touches workspaces — so orphaned shared rows accumulate and
|
|
248
|
+
// bloat the workspace panel. This sweep is the catch-all: drop any non-cleaned
|
|
249
|
+
// shared row whose owner is missing or offline, regardless of how it leaked.
|
|
250
|
+
export function pruneOrphanedSharedWorkspaces(): string[] {
|
|
251
|
+
return getDb().transaction(() => {
|
|
252
|
+
const orphanCondition = `
|
|
253
|
+
mode = 'shared' AND status != 'cleaned' AND (
|
|
254
|
+
owner_agent_id IS NULL
|
|
255
|
+
OR owner_agent_id NOT IN (SELECT id FROM agents)
|
|
256
|
+
OR owner_agent_id IN (SELECT id FROM agents WHERE status = 'offline')
|
|
257
|
+
)`;
|
|
258
|
+
const rows = getDb().query(`SELECT id FROM workspaces WHERE ${orphanCondition}`).all() as Array<{ id: string }>;
|
|
259
|
+
if (!rows.length) return [];
|
|
260
|
+
getDb().query(`DELETE FROM workspaces WHERE ${orphanCondition}`).run();
|
|
261
|
+
return rows.map((r) => r.id);
|
|
262
|
+
})();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Late-bound listeners for "a workspace row changed in a way that may change its
|
|
266
|
+
// owner's branch-state badge" (#236). A hook, not a direct emit, because getDb().ts is
|
|
267
|
+
// the lowest layer — sse.ts imports db, so db can't import sse without a cycle. sse
|
|
268
|
+
// registers a listener at startup that re-emits the owner agent's status over SSE.
|
|
269
|
+
export type WorkspaceChangeListener = (workspace: WorkspaceRecord) => void;
|
|
270
|
+
export const workspaceChangeListeners = new Set<WorkspaceChangeListener>();
|
|
271
|
+
export function onWorkspaceChange(listener: WorkspaceChangeListener): void {
|
|
272
|
+
workspaceChangeListeners.add(listener);
|
|
273
|
+
}
|
|
274
|
+
export function emitWorkspaceChange(workspace: WorkspaceRecord | null | undefined): void {
|
|
275
|
+
if (!workspace) return;
|
|
276
|
+
for (const listener of workspaceChangeListeners) {
|
|
277
|
+
try {
|
|
278
|
+
listener(workspace);
|
|
279
|
+
} catch {
|
|
280
|
+
// A badge-refresh listener must never break a workspace write.
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metadata: Record<string, unknown> = {}): WorkspaceRecord | null {
|
|
286
|
+
const existing = getWorkspace(id);
|
|
287
|
+
if (!existing) return null;
|
|
288
|
+
const nextMeta = { ...existing.metadata, ...metadata };
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
getDb().query(`
|
|
291
|
+
UPDATE workspaces
|
|
292
|
+
SET status = ?, metadata = ?, updated_at = ?, ready_at = coalesce(ready_at, ?), cleaned_at = coalesce(cleaned_at, ?)
|
|
293
|
+
WHERE id = ?
|
|
294
|
+
`).run(
|
|
295
|
+
status,
|
|
296
|
+
JSON.stringify(nextMeta),
|
|
297
|
+
now,
|
|
298
|
+
status === "ready" ? now : null,
|
|
299
|
+
status === "cleaned" ? now : null,
|
|
300
|
+
id,
|
|
301
|
+
);
|
|
302
|
+
electWorkspaceStewards(existing.repoRoot);
|
|
303
|
+
const updated = getWorkspace(id);
|
|
304
|
+
// Every status transition can flip the owner's badge (active→ready→steward→merged),
|
|
305
|
+
// so refresh it regardless of caller. Fires on the row's CURRENT owner.
|
|
306
|
+
emitWorkspaceChange(updated);
|
|
307
|
+
return updated;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Repoint a workspace row at a recycled branch after a land-and-continue merge
|
|
311
|
+
// (#206): the worktree switched to a fresh branch cut from the advanced base, so
|
|
312
|
+
// the row must track the new branch (else the next merge command targets a branch
|
|
313
|
+
// that no longer exists) and the new base sha. No-op if the row is gone.
|
|
314
|
+
export function setWorkspaceBranch(id: string, branch: string, baseSha?: string): WorkspaceRecord | null {
|
|
315
|
+
const existing = getWorkspace(id);
|
|
316
|
+
if (!existing) return null;
|
|
317
|
+
getDb().query(`UPDATE workspaces SET branch = ?, base_sha = coalesce(?, base_sha), updated_at = ? WHERE id = ?`)
|
|
318
|
+
.run(branch, baseSha ?? null, Date.now(), id);
|
|
319
|
+
return getWorkspace(id);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Workspace statuses that count as "live" for stewardship — an agent owning one
|
|
323
|
+
// of these is a candidate steward; the repo is worth coordinating.
|
|
324
|
+
export const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
|
|
325
|
+
|
|
326
|
+
export interface RepoStewardRecord {
|
|
327
|
+
repoRoot: string;
|
|
328
|
+
stewardAgentId?: string;
|
|
329
|
+
lastStewardAgentId?: string;
|
|
330
|
+
electedAt?: number;
|
|
331
|
+
updatedAt: number;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function rowToRepoSteward(row: any): RepoStewardRecord {
|
|
335
|
+
return {
|
|
336
|
+
repoRoot: row.repo_root,
|
|
337
|
+
stewardAgentId: row.steward_agent_id ?? undefined,
|
|
338
|
+
lastStewardAgentId: row.last_steward_agent_id ?? undefined,
|
|
339
|
+
electedAt: row.elected_at ?? undefined,
|
|
340
|
+
updatedAt: row.updated_at,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function getRepoSteward(repoRoot: string): RepoStewardRecord | null {
|
|
345
|
+
const row = getDb().query("SELECT * FROM repo_stewards WHERE repo_root = ?").get(repoRoot) as any;
|
|
346
|
+
return row ? rowToRepoSteward(row) : null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function listRepoStewards(): RepoStewardRecord[] {
|
|
350
|
+
return (getDb().query("SELECT * FROM repo_stewards ORDER BY updated_at DESC").all() as any[]).map(rowToRepoSteward);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Persist the elected steward for a repo. The row is never deleted, so a repo's
|
|
354
|
+
// stewardship survives a full all-agents-offline gap (steward goes NULL/dormant,
|
|
355
|
+
// last_steward_agent_id keeps continuity) and resumes on the next agent join.
|
|
356
|
+
export function upsertRepoSteward(repoRoot: string, steward: string | null, now: number): void {
|
|
357
|
+
getDb().query(`
|
|
358
|
+
INSERT INTO repo_stewards (repo_root, steward_agent_id, last_steward_agent_id, elected_at, updated_at)
|
|
359
|
+
VALUES ($repoRoot, $steward, $steward, $electedAt, $now)
|
|
360
|
+
ON CONFLICT(repo_root) DO UPDATE SET
|
|
361
|
+
steward_agent_id = $steward,
|
|
362
|
+
last_steward_agent_id = coalesce($steward, repo_stewards.last_steward_agent_id),
|
|
363
|
+
elected_at = CASE
|
|
364
|
+
WHEN $steward IS NOT NULL AND $steward IS NOT repo_stewards.steward_agent_id THEN $now
|
|
365
|
+
ELSE repo_stewards.elected_at
|
|
366
|
+
END,
|
|
367
|
+
updated_at = $now
|
|
368
|
+
`).run({ $repoRoot: repoRoot, $steward: steward, $electedAt: steward ? now : null, $now: now });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function electWorkspaceStewards(repoRoot?: string): void {
|
|
372
|
+
const params: string[] = repoRoot ? [repoRoot] : [];
|
|
373
|
+
const repoRows = getDb().query(`
|
|
374
|
+
SELECT DISTINCT repo_root FROM workspaces
|
|
375
|
+
WHERE status IN (${STEWARD_LIVE_STATUSES})
|
|
376
|
+
${repoRoot ? "AND repo_root = ?" : ""}
|
|
377
|
+
`).all(...params) as Array<{ repo_root: string }>;
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
for (const row of repoRows) {
|
|
380
|
+
// Candidate pool: owners of live workspaces in this repo who are online,
|
|
381
|
+
// oldest first. A steward must be an online agent actively in the repo — an
|
|
382
|
+
// offline agent can't coordinate, so it is never elected (the old bug).
|
|
383
|
+
const pool = (getDb().query(`
|
|
384
|
+
SELECT w.owner_agent_id AS id, MIN(w.created_at) AS created_at
|
|
385
|
+
FROM workspaces w JOIN agents a ON a.id = w.owner_agent_id
|
|
386
|
+
WHERE w.repo_root = ? AND w.owner_agent_id IS NOT NULL
|
|
387
|
+
AND a.status != 'offline' AND w.status IN (${STEWARD_LIVE_STATUSES})
|
|
388
|
+
GROUP BY w.owner_agent_id
|
|
389
|
+
ORDER BY created_at ASC
|
|
390
|
+
`).all(row.repo_root) as Array<{ id: string }>).map((r) => r.id);
|
|
391
|
+
|
|
392
|
+
// Keep the current steward if it is still in the pool (stable election);
|
|
393
|
+
// otherwise promote the oldest online owner, else go dormant (null).
|
|
394
|
+
const current = getRepoSteward(row.repo_root)?.stewardAgentId ?? null;
|
|
395
|
+
const steward = (current && pool.includes(current) ? current : pool[0]) ?? null;
|
|
396
|
+
|
|
397
|
+
upsertRepoSteward(row.repo_root, steward, now);
|
|
398
|
+
// Mirror onto live workspace rows only when the steward actually changed, so
|
|
399
|
+
// re-elections don't churn updated_at and reset the auto-abandon clock for a
|
|
400
|
+
// dormant repo (a stranded review_requested must still age out).
|
|
401
|
+
if (steward !== current) {
|
|
402
|
+
getDb().query(`UPDATE workspaces SET steward_agent_id = ?, updated_at = ? WHERE repo_root = ? AND status IN (${STEWARD_LIVE_STATUSES})`)
|
|
403
|
+
.run(steward, now, row.repo_root);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Public re-election trigger that does not change any workspace status — used by
|
|
409
|
+
// maintenance to revive a dormant steward (e.g. on the next agent join) before
|
|
410
|
+
// deciding whether a stranded worktree needs escalation.
|
|
411
|
+
export function reelectRepoSteward(repoRoot: string): void {
|
|
412
|
+
electWorkspaceStewards(repoRoot);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Merge a metadata patch into a workspace WITHOUT bumping updated_at or running a
|
|
416
|
+
// steward election. For maintenance bookkeeping (stranded/escalation markers)
|
|
417
|
+
// that must not disturb age-based GC timers. undefined values delete keys.
|
|
418
|
+
export function patchWorkspaceMetadata(id: string, patch: Record<string, unknown>): WorkspaceRecord | null {
|
|
419
|
+
const existing = getWorkspace(id);
|
|
420
|
+
if (!existing) return null;
|
|
421
|
+
const next = { ...existing.metadata };
|
|
422
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
423
|
+
if (v === undefined) delete next[k];
|
|
424
|
+
else next[k] = v;
|
|
425
|
+
}
|
|
426
|
+
getDb().query("UPDATE workspaces SET metadata = ? WHERE id = ?").run(JSON.stringify(next), id);
|
|
427
|
+
return getWorkspace(id);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Re-elect stewards for every repo where an agent owns a live workspace. Called
|
|
431
|
+
// when an agent (re)registers so a dormant repo regains a steward on rejoin
|
|
432
|
+
// without a full unscoped sweep.
|
|
433
|
+
export function electWorkspaceStewardsForAgent(agentId: string): void {
|
|
434
|
+
const repos = getDb().query(`
|
|
435
|
+
SELECT DISTINCT repo_root FROM workspaces
|
|
436
|
+
WHERE owner_agent_id = ? AND status IN (${STEWARD_LIVE_STATUSES})
|
|
437
|
+
`).all(agentId) as Array<{ repo_root: string }>;
|
|
438
|
+
for (const r of repos) electWorkspaceStewards(r.repo_root);
|
|
439
|
+
}
|
|
440
|
+
|