agent-relay-server 0.33.0 → 0.33.1
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/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,358 @@
|
|
|
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 { deleteAgent, heartbeat, upsertAgent } from "./agents.ts";
|
|
18
|
+
import { getDb } from "./connection.ts";
|
|
19
|
+
import { electWorkspaceStewards, getWorkspace, upsertWorkspaceFromManagedAgent } from "./workspaces.ts";
|
|
20
|
+
import type {
|
|
21
|
+
AgentCard,
|
|
22
|
+
ActivityEvent,
|
|
23
|
+
ActivityEventInput,
|
|
24
|
+
AgentKind,
|
|
25
|
+
AgentSessionGuard,
|
|
26
|
+
Artifact,
|
|
27
|
+
ArtifactBlob,
|
|
28
|
+
ArtifactKind,
|
|
29
|
+
ArtifactLink,
|
|
30
|
+
ArtifactSensitivity,
|
|
31
|
+
ArtifactVisibility,
|
|
32
|
+
AttachmentRef,
|
|
33
|
+
ChannelBinding,
|
|
34
|
+
ChannelBindingMode,
|
|
35
|
+
ChannelRouteTarget,
|
|
36
|
+
ChatHistoryImport,
|
|
37
|
+
ChatHistoryImportEntry,
|
|
38
|
+
ChannelSummary,
|
|
39
|
+
ChannelTargetHealth,
|
|
40
|
+
CreatePairInput,
|
|
41
|
+
HealthCheck,
|
|
42
|
+
HealthReport,
|
|
43
|
+
ManagedAgent,
|
|
44
|
+
ManagedSessionExitDiagnostics,
|
|
45
|
+
Message,
|
|
46
|
+
MessageDeliveryAttempt,
|
|
47
|
+
MessageDeliveryStatus,
|
|
48
|
+
Orchestrator,
|
|
49
|
+
OrchestratorHealth,
|
|
50
|
+
OrchestratorRuntimeInput,
|
|
51
|
+
OrchestratorStatus,
|
|
52
|
+
OrchestratorUpgradeState,
|
|
53
|
+
PairActionInput,
|
|
54
|
+
PairMessageInput,
|
|
55
|
+
PairSession,
|
|
56
|
+
PairStatus,
|
|
57
|
+
RegisterAgentInput,
|
|
58
|
+
ReplyObligation,
|
|
59
|
+
RegisterOrchestratorInput,
|
|
60
|
+
SendMessageInput,
|
|
61
|
+
PollQuery,
|
|
62
|
+
SpawnApprovalMode,
|
|
63
|
+
SpawnProvider,
|
|
64
|
+
Task,
|
|
65
|
+
TaskEvent,
|
|
66
|
+
TaskSeverity,
|
|
67
|
+
TaskStatus,
|
|
68
|
+
IntegrationEventInput,
|
|
69
|
+
IntegrationSummary,
|
|
70
|
+
IntegrationTaskStats,
|
|
71
|
+
InboxDraft,
|
|
72
|
+
InboxState,
|
|
73
|
+
InboxThreadState,
|
|
74
|
+
ContextSnapshot,
|
|
75
|
+
ContextState,
|
|
76
|
+
ProviderCapabilities,
|
|
77
|
+
TaskStatusInput,
|
|
78
|
+
WorkspaceMetadata,
|
|
79
|
+
WorkspaceRecord,
|
|
80
|
+
WorkspaceStatus,
|
|
81
|
+
} from "../types";
|
|
82
|
+
|
|
83
|
+
// --- Orchestrators ---
|
|
84
|
+
|
|
85
|
+
export function rowToOrchestrator(row: any): Orchestrator {
|
|
86
|
+
const meta = parseJson<Record<string, unknown>>(row.meta, {});
|
|
87
|
+
const runtimePackage = parseRuntimePackage(meta.package);
|
|
88
|
+
const version = stringValue(meta.version);
|
|
89
|
+
const gitSha = stringValue(meta.gitSha);
|
|
90
|
+
const protocolRaw = meta.protocolVersion;
|
|
91
|
+
const protocolVersion = typeof protocolRaw === "number"
|
|
92
|
+
? protocolRaw
|
|
93
|
+
: typeof protocolRaw === "string" && protocolRaw.trim() !== ""
|
|
94
|
+
? Number(protocolRaw)
|
|
95
|
+
: undefined;
|
|
96
|
+
const contracts = parseRuntimeContracts(meta.contracts, Number.isFinite(protocolVersion) ? { orchestratorProtocol: protocolVersion } : {});
|
|
97
|
+
const capabilities = parseRuntimeCapabilities(meta.capabilities);
|
|
98
|
+
const providerStatus = Array.isArray(meta.providerStatus) ? meta.providerStatus as Orchestrator["providerStatus"] : undefined;
|
|
99
|
+
const providerCatalog = Array.isArray(meta.providerCatalog) ? meta.providerCatalog as Orchestrator["providerCatalog"] : undefined;
|
|
100
|
+
const compatibility = contractCompatibility(contracts, { orchestratorProtocol: CONTRACT_REQUIREMENTS.orchestratorProtocol });
|
|
101
|
+
const supervisorRaw = stringValue(meta.supervisor);
|
|
102
|
+
const supervisor = supervisorRaw === "systemd" || supervisorRaw === "launchd" || supervisorRaw === "process" || supervisorRaw === "unknown" ? supervisorRaw : undefined;
|
|
103
|
+
const selfUnit = stringValue(meta.selfUnit);
|
|
104
|
+
const runtimePrefix = stringValue(meta.runtimePrefix);
|
|
105
|
+
const upgrade = parseOrchestratorUpgrade(meta.upgrade);
|
|
106
|
+
return {
|
|
107
|
+
id: row.id,
|
|
108
|
+
hostname: row.hostname,
|
|
109
|
+
status: row.status as OrchestratorStatus,
|
|
110
|
+
agentId: row.agent_id,
|
|
111
|
+
providers: parseJson<SpawnProvider[]>(row.providers, []),
|
|
112
|
+
...(providerStatus ? { providerStatus } : {}),
|
|
113
|
+
...(providerCatalog ? { providerCatalog } : {}),
|
|
114
|
+
baseDir: row.base_dir,
|
|
115
|
+
...(row.api_url ? { apiUrl: row.api_url } : {}),
|
|
116
|
+
envKeys: parseJson<string[]>(row.env_keys, []),
|
|
117
|
+
...(runtimePackage ? { package: runtimePackage } : {}),
|
|
118
|
+
...(Object.keys(contracts).length ? { contracts } : {}),
|
|
119
|
+
...(Object.keys(capabilities).length ? { capabilities } : {}),
|
|
120
|
+
contractCompatibility: compatibility,
|
|
121
|
+
...(version ? { version } : {}),
|
|
122
|
+
...(Number.isFinite(protocolVersion) ? { protocolVersion } : {}),
|
|
123
|
+
...(gitSha ? { gitSha } : {}),
|
|
124
|
+
health: orchestratorHealth(version, compatibility),
|
|
125
|
+
...(supervisor ? { supervisor } : {}),
|
|
126
|
+
...(selfUnit ? { selfUnit } : {}),
|
|
127
|
+
...(runtimePrefix ? { runtimePrefix } : {}),
|
|
128
|
+
...(upgrade ? { upgrade } : {}),
|
|
129
|
+
meta,
|
|
130
|
+
managedAgents: parseJson<ManagedAgent[]>(row.managed_agents, []),
|
|
131
|
+
lastSeen: row.last_seen,
|
|
132
|
+
createdAt: row.created_at,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function parseOrchestratorUpgrade(value: unknown): OrchestratorUpgradeState | undefined {
|
|
137
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
138
|
+
const v = value as Record<string, unknown>;
|
|
139
|
+
const desiredVersion = stringValue(v.desiredVersion);
|
|
140
|
+
const status = v.status;
|
|
141
|
+
if (!desiredVersion || (status !== "pending" && status !== "succeeded" && status !== "failed")) return undefined;
|
|
142
|
+
return {
|
|
143
|
+
desiredVersion,
|
|
144
|
+
status,
|
|
145
|
+
...(stringValue(v.commandId) ? { commandId: stringValue(v.commandId) } : {}),
|
|
146
|
+
...(Array.isArray(v.providers) ? { providers: v.providers.filter((p): p is string => typeof p === "string") } : {}),
|
|
147
|
+
...(stringValue(v.fromVersion) ? { fromVersion: stringValue(v.fromVersion) } : {}),
|
|
148
|
+
...(stringValue(v.requestedBy) ? { requestedBy: stringValue(v.requestedBy) } : {}),
|
|
149
|
+
requestedAt: typeof v.requestedAt === "number" ? v.requestedAt : 0,
|
|
150
|
+
...(typeof v.settledAt === "number" ? { settledAt: v.settledAt } : {}),
|
|
151
|
+
...(stringValue(v.error) ? { error: stringValue(v.error) } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Set or clear the orchestrator's in-flight/last upgrade state. Stored in the
|
|
157
|
+
* meta blob (no schema change). Pass null to clear.
|
|
158
|
+
*/
|
|
159
|
+
export function setOrchestratorUpgradeState(id: string, state: OrchestratorUpgradeState | null): Orchestrator | null {
|
|
160
|
+
const row = getDb().query("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
|
|
161
|
+
if (!row) return null;
|
|
162
|
+
const meta = parseJson<Record<string, unknown>>(row.meta ?? "{}", {});
|
|
163
|
+
if (state) meta.upgrade = state;
|
|
164
|
+
else delete meta.upgrade;
|
|
165
|
+
getDb().query("UPDATE orchestrators SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id);
|
|
166
|
+
return getOrchestrator(id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
export function orchestratorHealth(version: string | undefined, compatibility: ReturnType<typeof contractCompatibility>): OrchestratorHealth {
|
|
171
|
+
const issues: OrchestratorHealth["issues"] = [];
|
|
172
|
+
if (!version) {
|
|
173
|
+
issues.push({ code: "missing-version", detail: "Orchestrator did not report a package version." });
|
|
174
|
+
} else if (version !== VERSION) {
|
|
175
|
+
issues.push({ code: "package-drift", detail: `Orchestrator package ${version} differs from server package ${VERSION}; contract compatibility decides runtime health.` });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const issue of compatibility.issues) {
|
|
179
|
+
issues.push({
|
|
180
|
+
code: issue.actual === undefined ? "missing-contract" : "protocol-mismatch",
|
|
181
|
+
detail: issue.actual === undefined
|
|
182
|
+
? `Orchestrator did not report ${issue.contract}; expected ${issue.expected}.`
|
|
183
|
+
: `Orchestrator ${issue.contract} ${issue.actual} is incompatible; expected ${issue.expected}.`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (compatibility.status === "incompatible") {
|
|
188
|
+
issues.push({ code: "upgrade-required", detail: "Upgrade the orchestrator to a compatible protocol before relying on host lifecycle control." });
|
|
189
|
+
} else if (compatibility.status === "unknown") {
|
|
190
|
+
issues.push({ code: "restart-required", detail: "Restart or upgrade the orchestrator so it reports runtime contract metadata." });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
status: compatibility.status === "incompatible"
|
|
195
|
+
? "upgrade-required"
|
|
196
|
+
: compatibility.status === "unknown"
|
|
197
|
+
? "unknown"
|
|
198
|
+
: issues.length > 0
|
|
199
|
+
? "warn"
|
|
200
|
+
: "ok",
|
|
201
|
+
restartRequired: compatibility.status === "unknown",
|
|
202
|
+
upgradeRequired: compatibility.status === "incompatible",
|
|
203
|
+
issues,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function mergeOrchestratorRuntimeMeta(meta: Record<string, unknown>, input: OrchestratorRuntimeInput): Record<string, unknown> {
|
|
208
|
+
const contracts = parseRuntimeContracts(input.contracts, input.protocolVersion !== undefined ? { orchestratorProtocol: input.protocolVersion } : {});
|
|
209
|
+
const capabilities = parseRuntimeCapabilities(input.capabilities);
|
|
210
|
+
return {
|
|
211
|
+
...meta,
|
|
212
|
+
...(input.package ? { package: input.package } : {}),
|
|
213
|
+
...(Object.keys(contracts).length ? { contracts: { ...(parseRuntimeContracts(meta.contracts) as RuntimeContracts), ...contracts } } : {}),
|
|
214
|
+
...(Object.keys(capabilities).length ? { capabilities: { ...parseRuntimeCapabilities(meta.capabilities), ...capabilities } } : {}),
|
|
215
|
+
...(input.version ? { version: input.version } : {}),
|
|
216
|
+
...(input.protocolVersion !== undefined ? { protocolVersion: input.protocolVersion } : {}),
|
|
217
|
+
...(input.gitSha ? { gitSha: input.gitSha } : {}),
|
|
218
|
+
...(input.providers ? { providers: input.providers } : {}),
|
|
219
|
+
...(input.providerStatus ? { providerStatus: input.providerStatus } : {}),
|
|
220
|
+
...(input.providerCatalog ? { providerCatalog: input.providerCatalog } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
const agentId = `orchestrator-${input.id}`;
|
|
227
|
+
// Carry forward server-managed meta the orchestrator never reports (upgrade
|
|
228
|
+
// state) — registration meta would otherwise drop it on the very re-register
|
|
229
|
+
// that follows a self-upgrade restart, breaking version reconciliation.
|
|
230
|
+
const existingRow = getDb().query("SELECT meta FROM orchestrators WHERE id = ?").get(input.id) as { meta?: string } | undefined;
|
|
231
|
+
const existingMeta = existingRow ? parseJson<Record<string, unknown>>(existingRow.meta ?? "{}", {}) : {};
|
|
232
|
+
const mergedMeta = mergeOrchestratorRuntimeMeta(input.meta ?? {}, input);
|
|
233
|
+
if (existingMeta.upgrade !== undefined && mergedMeta.upgrade === undefined) mergedMeta.upgrade = existingMeta.upgrade;
|
|
234
|
+
const stmt = getDb().query(`
|
|
235
|
+
INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, api_url, env_keys, meta, last_seen, created_at)
|
|
236
|
+
VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $apiUrl, $envKeys, $meta, $now, $now)
|
|
237
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
238
|
+
hostname = $hostname,
|
|
239
|
+
status = 'online',
|
|
240
|
+
providers = $providers,
|
|
241
|
+
base_dir = $baseDir,
|
|
242
|
+
api_url = $apiUrl,
|
|
243
|
+
env_keys = $envKeys,
|
|
244
|
+
meta = $meta,
|
|
245
|
+
last_seen = $now
|
|
246
|
+
`);
|
|
247
|
+
stmt.run({
|
|
248
|
+
$id: input.id,
|
|
249
|
+
$hostname: input.hostname,
|
|
250
|
+
$agentId: agentId,
|
|
251
|
+
$providers: JSON.stringify(input.providers),
|
|
252
|
+
$baseDir: input.baseDir,
|
|
253
|
+
$apiUrl: input.apiUrl ?? null,
|
|
254
|
+
$envKeys: JSON.stringify(input.envKeys ?? []),
|
|
255
|
+
$meta: JSON.stringify(mergedMeta),
|
|
256
|
+
$now: now,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Also register as an agent so the orchestrator can receive messages
|
|
260
|
+
upsertAgent({
|
|
261
|
+
id: agentId,
|
|
262
|
+
name: `Orchestrator (${input.hostname})`,
|
|
263
|
+
tags: ["orchestrator", input.hostname],
|
|
264
|
+
machine: input.hostname,
|
|
265
|
+
capabilities: ["orchestrator", "spawn"],
|
|
266
|
+
status: "online",
|
|
267
|
+
ready: true,
|
|
268
|
+
meta: {
|
|
269
|
+
orchestratorId: input.id,
|
|
270
|
+
builtin: true,
|
|
271
|
+
...(input.package ? { package: input.package } : {}),
|
|
272
|
+
...(input.contracts ? { contracts: input.contracts } : {}),
|
|
273
|
+
...(input.capabilities ? { capabilities: input.capabilities } : {}),
|
|
274
|
+
...(input.version ? { version: input.version } : {}),
|
|
275
|
+
...(input.protocolVersion !== undefined ? { protocolVersion: input.protocolVersion } : {}),
|
|
276
|
+
...(input.gitSha ? { gitSha: input.gitSha } : {}),
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return getOrchestrator(input.id)!;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function getOrchestrator(id: string): Orchestrator | null {
|
|
284
|
+
const row = getDb().query("SELECT * FROM orchestrators WHERE id = ?").get(id) as any;
|
|
285
|
+
return row ? rowToOrchestrator(row) : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function listOrchestrators(): Orchestrator[] {
|
|
289
|
+
return (getDb().query("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function orchestratorHeartbeat(id: string, runtime: OrchestratorRuntimeInput = {}): Orchestrator | null {
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
const row = getDb().query("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
|
|
295
|
+
if (!row) return null;
|
|
296
|
+
const meta = mergeOrchestratorRuntimeMeta(parseJson<Record<string, unknown>>(row.meta ?? "{}", {}), runtime);
|
|
297
|
+
if (runtime.providers) {
|
|
298
|
+
getDb().query("UPDATE orchestrators SET last_seen = ?, status = 'online', providers = ?, meta = ? WHERE id = ?")
|
|
299
|
+
.run(now, JSON.stringify(runtime.providers), JSON.stringify(meta), id);
|
|
300
|
+
} else {
|
|
301
|
+
getDb().query("UPDATE orchestrators SET last_seen = ?, status = 'online', meta = ? WHERE id = ?").run(now, JSON.stringify(meta), id);
|
|
302
|
+
}
|
|
303
|
+
// Also heartbeat the agent
|
|
304
|
+
const orch = getOrchestrator(id);
|
|
305
|
+
if (orch) {
|
|
306
|
+
heartbeat(orch.agentId);
|
|
307
|
+
}
|
|
308
|
+
return orch;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchestrator | null {
|
|
312
|
+
for (const agent of agents) upsertWorkspaceFromManagedAgent(agent);
|
|
313
|
+
electWorkspaceStewards();
|
|
314
|
+
const enriched = agents.map((agent) => {
|
|
315
|
+
const workspaceId = agent.workspace?.id;
|
|
316
|
+
const workspace = workspaceId ? getWorkspace(workspaceId) : null;
|
|
317
|
+
return workspace ? {
|
|
318
|
+
...agent,
|
|
319
|
+
workspace: {
|
|
320
|
+
...agent.workspace,
|
|
321
|
+
id: workspace.id,
|
|
322
|
+
status: workspace.status,
|
|
323
|
+
stewardAgentId: workspace.stewardAgentId,
|
|
324
|
+
repoRoot: workspace.repoRoot,
|
|
325
|
+
sourceCwd: workspace.sourceCwd,
|
|
326
|
+
worktreePath: workspace.worktreePath,
|
|
327
|
+
branch: workspace.branch,
|
|
328
|
+
baseRef: workspace.baseRef,
|
|
329
|
+
baseSha: workspace.baseSha,
|
|
330
|
+
},
|
|
331
|
+
} : agent;
|
|
332
|
+
});
|
|
333
|
+
getDb().query("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
|
|
334
|
+
.run(JSON.stringify(enriched), Date.now(), id);
|
|
335
|
+
return getOrchestrator(id);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
export function deleteOrchestrator(id: string): boolean {
|
|
340
|
+
const orch = getOrchestrator(id);
|
|
341
|
+
if (!orch) return false;
|
|
342
|
+
getDb().query("DELETE FROM orchestrators WHERE id = ?").run(id);
|
|
343
|
+
deleteAgent(orch.agentId);
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function reapStaleOrchestrators(): string[] {
|
|
348
|
+
const cutoff = Date.now() - STALE_TTL_MS;
|
|
349
|
+
const stale = getDb().query("SELECT id, agent_id FROM orchestrators WHERE last_seen < ? AND status = 'online'").all(cutoff) as any[];
|
|
350
|
+
for (const row of stale) {
|
|
351
|
+
getDb().query("UPDATE orchestrators SET status = 'offline' WHERE id = ?").run(row.id);
|
|
352
|
+
// An orchestrator agent holds no workspaces, so use a direct status update
|
|
353
|
+
// instead of setStatus() (which triggers an unscoped electWorkspaceStewards
|
|
354
|
+
// sweep across all repos on every offline transition).
|
|
355
|
+
getDb().query("UPDATE agents SET status = 'offline', ready = 0 WHERE id = ?").run(row.agent_id);
|
|
356
|
+
}
|
|
357
|
+
return stale.map((row: any) => row.id);
|
|
358
|
+
}
|
package/src/db/pairs.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
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 { getAgent, listAgents } from "./agents.ts";
|
|
18
|
+
import { getDb } from "./connection.ts";
|
|
19
|
+
import { rowToPair } from "./mappers.ts";
|
|
20
|
+
import { sendMessage } from "./messages.ts";
|
|
21
|
+
import type {
|
|
22
|
+
AgentCard,
|
|
23
|
+
ActivityEvent,
|
|
24
|
+
ActivityEventInput,
|
|
25
|
+
AgentKind,
|
|
26
|
+
AgentSessionGuard,
|
|
27
|
+
Artifact,
|
|
28
|
+
ArtifactBlob,
|
|
29
|
+
ArtifactKind,
|
|
30
|
+
ArtifactLink,
|
|
31
|
+
ArtifactSensitivity,
|
|
32
|
+
ArtifactVisibility,
|
|
33
|
+
AttachmentRef,
|
|
34
|
+
ChannelBinding,
|
|
35
|
+
ChannelBindingMode,
|
|
36
|
+
ChannelRouteTarget,
|
|
37
|
+
ChatHistoryImport,
|
|
38
|
+
ChatHistoryImportEntry,
|
|
39
|
+
ChannelSummary,
|
|
40
|
+
ChannelTargetHealth,
|
|
41
|
+
CreatePairInput,
|
|
42
|
+
HealthCheck,
|
|
43
|
+
HealthReport,
|
|
44
|
+
ManagedAgent,
|
|
45
|
+
ManagedSessionExitDiagnostics,
|
|
46
|
+
Message,
|
|
47
|
+
MessageDeliveryAttempt,
|
|
48
|
+
MessageDeliveryStatus,
|
|
49
|
+
Orchestrator,
|
|
50
|
+
OrchestratorHealth,
|
|
51
|
+
OrchestratorRuntimeInput,
|
|
52
|
+
OrchestratorStatus,
|
|
53
|
+
OrchestratorUpgradeState,
|
|
54
|
+
PairActionInput,
|
|
55
|
+
PairMessageInput,
|
|
56
|
+
PairSession,
|
|
57
|
+
PairStatus,
|
|
58
|
+
RegisterAgentInput,
|
|
59
|
+
ReplyObligation,
|
|
60
|
+
RegisterOrchestratorInput,
|
|
61
|
+
SendMessageInput,
|
|
62
|
+
PollQuery,
|
|
63
|
+
SpawnApprovalMode,
|
|
64
|
+
SpawnProvider,
|
|
65
|
+
Task,
|
|
66
|
+
TaskEvent,
|
|
67
|
+
TaskSeverity,
|
|
68
|
+
TaskStatus,
|
|
69
|
+
IntegrationEventInput,
|
|
70
|
+
IntegrationSummary,
|
|
71
|
+
IntegrationTaskStats,
|
|
72
|
+
InboxDraft,
|
|
73
|
+
InboxState,
|
|
74
|
+
InboxThreadState,
|
|
75
|
+
ContextSnapshot,
|
|
76
|
+
ContextState,
|
|
77
|
+
ProviderCapabilities,
|
|
78
|
+
TaskStatusInput,
|
|
79
|
+
WorkspaceMetadata,
|
|
80
|
+
WorkspaceRecord,
|
|
81
|
+
WorkspaceStatus,
|
|
82
|
+
} from "../types";
|
|
83
|
+
|
|
84
|
+
export const OPEN_PAIR_STATUSES = ["pending", "active"] as const;
|
|
85
|
+
export const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
|
|
86
|
+
export const MAX_PAIR_TTL_MS = DAY_MS;
|
|
87
|
+
|
|
88
|
+
export function expirePendingPairs(now: number = Date.now()): void {
|
|
89
|
+
getDb().query("UPDATE pairs SET status = 'expired', updated_at = ?, ended_at = ? WHERE status = 'pending' AND expires_at <= ?")
|
|
90
|
+
.run(now, now, now);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void {
|
|
94
|
+
getDb().query(`
|
|
95
|
+
UPDATE pairs
|
|
96
|
+
SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ?
|
|
97
|
+
WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
|
|
98
|
+
`).run(now, agentId, now, agentId, agentId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getOpenPairForAgent(agentId: string): PairSession | null {
|
|
102
|
+
expirePendingPairs();
|
|
103
|
+
const row = getDb().query(`
|
|
104
|
+
SELECT * FROM pairs
|
|
105
|
+
WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
|
|
106
|
+
ORDER BY updated_at DESC
|
|
107
|
+
LIMIT 1
|
|
108
|
+
`).get(agentId, agentId) as any;
|
|
109
|
+
return row ? rowToPair(row) : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function pairParticipant(pair: PairSession, agentId: string): boolean {
|
|
113
|
+
return pair.requesterId === agentId || pair.targetId === agentId;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function pairPeer(pair: PairSession, agentId: string): string {
|
|
117
|
+
return pair.requesterId === agentId ? pair.targetId : pair.requesterId;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolvePairTarget(target: string, requesterId: string): {
|
|
121
|
+
ok: true;
|
|
122
|
+
agent: AgentCard;
|
|
123
|
+
} | {
|
|
124
|
+
ok: false;
|
|
125
|
+
error: string;
|
|
126
|
+
code: "not_found" | "ambiguous" | "busy" | "offline";
|
|
127
|
+
matches?: AgentCard[];
|
|
128
|
+
busy?: Array<{ agent: AgentCard; pair: PairSession }>;
|
|
129
|
+
} {
|
|
130
|
+
const matches = matchAgents(target, listAgents(), { excludeId: requesterId });
|
|
131
|
+
if (matches.length === 0) return { ok: false, code: "not_found", error: `no agent matches ${target}` };
|
|
132
|
+
|
|
133
|
+
const live = matches.filter((agent) => agent.status !== "offline" && agent.ready);
|
|
134
|
+
if (live.length === 0) return { ok: false, code: "offline", error: `no matching agent is online and ready`, matches };
|
|
135
|
+
|
|
136
|
+
const busy: Array<{ agent: AgentCard; pair: PairSession }> = [];
|
|
137
|
+
const available: AgentCard[] = [];
|
|
138
|
+
for (const agent of live) {
|
|
139
|
+
const openPair = getOpenPairForAgent(agent.id);
|
|
140
|
+
if (openPair) busy.push({ agent, pair: openPair });
|
|
141
|
+
else available.push(agent);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (available.length === 0) return { ok: false, code: "busy", error: `matching agent is already paired`, busy };
|
|
145
|
+
if (available.length > 1) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
code: "ambiguous",
|
|
149
|
+
error: `target ${target} matches ${available.length} available agents`,
|
|
150
|
+
matches: available,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, agent: available[0]! };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function pairSystemMessage(pair: PairSession, to: string, event: string, subject: string, body: string): Message {
|
|
157
|
+
return sendMessage({
|
|
158
|
+
from: "system",
|
|
159
|
+
to,
|
|
160
|
+
kind: "pair",
|
|
161
|
+
subject,
|
|
162
|
+
body,
|
|
163
|
+
payload: {
|
|
164
|
+
pairId: pair.id,
|
|
165
|
+
pairEvent: event,
|
|
166
|
+
requesterId: pair.requesterId,
|
|
167
|
+
targetId: pair.targetId,
|
|
168
|
+
pairStatus: pair.status,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function getPair(id: string): PairSession | null {
|
|
174
|
+
expirePendingPairs();
|
|
175
|
+
const row = getDb().query("SELECT * FROM pairs WHERE id = ?").get(id) as any;
|
|
176
|
+
return row ? rowToPair(row) : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function listPairs(filter?: { agentId?: string; status?: PairStatus }): PairSession[] {
|
|
180
|
+
expirePendingPairs();
|
|
181
|
+
const conditions: string[] = [];
|
|
182
|
+
const params: any[] = [];
|
|
183
|
+
if (filter?.agentId) {
|
|
184
|
+
conditions.push("(requester_id = ? OR target_id = ?)");
|
|
185
|
+
params.push(filter.agentId, filter.agentId);
|
|
186
|
+
}
|
|
187
|
+
if (filter?.status) {
|
|
188
|
+
conditions.push("status = ?");
|
|
189
|
+
params.push(filter.status);
|
|
190
|
+
}
|
|
191
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
192
|
+
return (getDb().query(`SELECT * FROM pairs ${where} ORDER BY updated_at DESC LIMIT 100`).all(...params) as any[]).map(rowToPair);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function createPair(input: CreatePairInput): {
|
|
196
|
+
ok: true;
|
|
197
|
+
pair: PairSession;
|
|
198
|
+
invite: Message;
|
|
199
|
+
} | {
|
|
200
|
+
ok: false;
|
|
201
|
+
error: string;
|
|
202
|
+
code: "not_found" | "ambiguous" | "busy" | "offline" | "invalid";
|
|
203
|
+
matches?: AgentCard[];
|
|
204
|
+
busy?: Array<{ agent: AgentCard; pair: PairSession }>;
|
|
205
|
+
} {
|
|
206
|
+
expirePendingPairs();
|
|
207
|
+
const requester = getAgent(input.from);
|
|
208
|
+
if (!requester) return { ok: false, code: "invalid", error: `requester agent ${input.from} not registered` };
|
|
209
|
+
if (requester.status === "offline" || !requester.ready) return { ok: false, code: "offline", error: `requester agent ${input.from} is not online and ready` };
|
|
210
|
+
const requesterPair = getOpenPairForAgent(input.from);
|
|
211
|
+
if (requesterPair) return { ok: false, code: "busy", error: `requester is already paired`, busy: [{ agent: requester, pair: requesterPair }] };
|
|
212
|
+
|
|
213
|
+
const resolved = resolvePairTarget(input.target, input.from);
|
|
214
|
+
if (!resolved.ok) return resolved;
|
|
215
|
+
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
const ttlMs = Math.min(Math.max(input.ttlMs ?? DEFAULT_PAIR_TTL_MS, 10_000), MAX_PAIR_TTL_MS);
|
|
218
|
+
const id = randomUUID();
|
|
219
|
+
getDb().query(`
|
|
220
|
+
INSERT INTO pairs (id, requester_id, target_id, status, objective, meta, created_at, updated_at, expires_at)
|
|
221
|
+
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?)
|
|
222
|
+
`).run(id, input.from, resolved.agent.id, input.objective ?? null, JSON.stringify(input.meta ?? {}), now, now, now + ttlMs);
|
|
223
|
+
const pair = getPair(id)!;
|
|
224
|
+
const objective = pair.objective ? `\n\nObjective:\n${pair.objective}` : "";
|
|
225
|
+
const invite = pairSystemMessage(
|
|
226
|
+
pair,
|
|
227
|
+
pair.targetId,
|
|
228
|
+
"invite",
|
|
229
|
+
`Pair invite from ${pair.requesterId}`,
|
|
230
|
+
[
|
|
231
|
+
`${pair.requesterId} wants to start a two-party live pair session with you.${objective}`,
|
|
232
|
+
"",
|
|
233
|
+
`Pair ID: ${pair.id}`,
|
|
234
|
+
"Accept with POST /api/pairs/{id}/accept using your agentId.",
|
|
235
|
+
"Reject with POST /api/pairs/{id}/reject using your agentId.",
|
|
236
|
+
"Pairing is exclusive: each agent can be in at most one pending or active pair.",
|
|
237
|
+
].join("\n"),
|
|
238
|
+
);
|
|
239
|
+
return { ok: true, pair, invite };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function acceptPair(id: string, input: PairActionInput): { ok: true; pair: PairSession; notices: Message[] } | { ok: false; error: string; code: "not_found" | "forbidden" | "busy" | "invalid" } {
|
|
243
|
+
expirePendingPairs();
|
|
244
|
+
const pair = getPair(id);
|
|
245
|
+
if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
|
|
246
|
+
if (pair.status !== "pending") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
247
|
+
if (pair.targetId !== input.agentId) return { ok: false, code: "forbidden", error: "only the target agent can accept this pair" };
|
|
248
|
+
const target = getAgent(input.agentId);
|
|
249
|
+
if (!target || target.status === "offline" || !target.ready) return { ok: false, code: "invalid", error: "accepting agent is not online and ready" };
|
|
250
|
+
const requester = getAgent(pair.requesterId);
|
|
251
|
+
if (!requester || requester.status === "offline" || !requester.ready) return { ok: false, code: "invalid", error: "requester agent is no longer online and ready" };
|
|
252
|
+
|
|
253
|
+
for (const agentId of [pair.requesterId, pair.targetId]) {
|
|
254
|
+
const open = getOpenPairForAgent(agentId);
|
|
255
|
+
if (open && open.id !== pair.id) return { ok: false, code: "busy", error: `${agentId} is already paired` };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
getDb().query("UPDATE pairs SET status = 'active', accepted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
|
|
260
|
+
.run(now, now, id);
|
|
261
|
+
const active = getPair(id)!;
|
|
262
|
+
const notices = [
|
|
263
|
+
pairSystemMessage(active, active.requesterId, "accepted", `Pair accepted by ${active.targetId}`, `Pair ${active.id} is active. Send pair messages with POST /api/pairs/${active.id}/messages.`),
|
|
264
|
+
pairSystemMessage(active, active.targetId, "accepted", `Pair active with ${active.requesterId}`, `Pair ${active.id} is active. Send pair messages with POST /api/pairs/${active.id}/messages.`),
|
|
265
|
+
];
|
|
266
|
+
return { ok: true, pair: active, notices };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function rejectPair(id: string, input: PairActionInput): { ok: true; pair: PairSession; notice: Message } | { ok: false; error: string; code: "not_found" | "forbidden" | "invalid" } {
|
|
270
|
+
expirePendingPairs();
|
|
271
|
+
const pair = getPair(id);
|
|
272
|
+
if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
|
|
273
|
+
if (pair.status !== "pending") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
274
|
+
if (pair.targetId !== input.agentId) return { ok: false, code: "forbidden", error: "only the target agent can reject this pair" };
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
getDb().query("UPDATE pairs SET status = 'rejected', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
|
|
277
|
+
.run(now, input.agentId, now, id);
|
|
278
|
+
const rejected = getPair(id)!;
|
|
279
|
+
const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
|
|
280
|
+
const notice = pairSystemMessage(rejected, rejected.requesterId, "rejected", `Pair rejected by ${input.agentId}`, `Pair ${rejected.id} was rejected.${reason}`);
|
|
281
|
+
return { ok: true, pair: rejected, notice };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function endPair(id: string, input: PairActionInput): { ok: true; pair: PairSession; notice?: Message } | { ok: false; error: string; code: "not_found" | "forbidden" | "invalid" } {
|
|
285
|
+
expirePendingPairs();
|
|
286
|
+
const pair = getPair(id);
|
|
287
|
+
if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
|
|
288
|
+
if (!pairParticipant(pair, input.agentId)) return { ok: false, code: "forbidden", error: "only pair participants can hang up" };
|
|
289
|
+
if (!OPEN_PAIR_STATUSES.includes(pair.status as any)) return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
getDb().query("UPDATE pairs SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
|
|
292
|
+
.run(now, input.agentId, now, id);
|
|
293
|
+
const ended = getPair(id)!;
|
|
294
|
+
const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
|
|
295
|
+
const peer = pairPeer(ended, input.agentId);
|
|
296
|
+
const notice = pairSystemMessage(ended, peer, "ended", `Pair ended by ${input.agentId}`, `Pair ${ended.id} ended.${reason}`);
|
|
297
|
+
return { ok: true, pair: ended, notice };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function sendPairMessage(id: string, input: PairMessageInput): { ok: true; pair: PairSession; message: Message } | { ok: false; error: string; code: "not_found" | "forbidden" | "invalid" } {
|
|
301
|
+
expirePendingPairs();
|
|
302
|
+
const pair = getPair(id);
|
|
303
|
+
if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
|
|
304
|
+
if (pair.status !== "active") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
305
|
+
if (!pairParticipant(pair, input.from)) return { ok: false, code: "forbidden", error: "only pair participants can send pair messages" };
|
|
306
|
+
const to = pairPeer(pair, input.from);
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
const message = sendMessage({
|
|
309
|
+
from: input.from,
|
|
310
|
+
to,
|
|
311
|
+
kind: "pair",
|
|
312
|
+
subject: input.subject ?? `Pair ${pair.id}`,
|
|
313
|
+
body: input.body,
|
|
314
|
+
payload: {
|
|
315
|
+
pairId: pair.id,
|
|
316
|
+
pairEvent: "message",
|
|
317
|
+
requesterId: pair.requesterId,
|
|
318
|
+
targetId: pair.targetId,
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
getDb().query("UPDATE pairs SET last_message_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
|
|
322
|
+
return { ok: true, pair: getPair(id)!, message };
|
|
323
|
+
}
|
|
324
|
+
|