agent-relay-server 0.11.6 → 0.11.9
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/docs/openapi.json +211 -1
- package/package.json +7 -5
- package/public/index.html +6012 -1098
- package/runner/src/adapter.ts +4 -0
- package/src/bus.ts +42 -0
- package/src/config-store.ts +58 -0
- package/src/config.ts +4 -0
- package/src/db.ts +224 -21
- package/src/maintenance.ts +394 -24
- package/src/routes.ts +223 -63
- package/src/runtime-tokens.ts +44 -1
- package/src/security.ts +17 -0
- package/src/steward.ts +117 -0
- package/src/workspace-merge.ts +108 -0
package/runner/src/adapter.ts
CHANGED
package/src/bus.ts
CHANGED
|
@@ -159,6 +159,7 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
159
159
|
}
|
|
160
160
|
const after = getAgent(conn.agentId);
|
|
161
161
|
auditProviderStateTransition(conn.agentId, before, after);
|
|
162
|
+
auditRunnerTimelineEvent(conn.agentId, after?.meta?.timelineEvent);
|
|
162
163
|
// A real PreCompact/SessionStart hook arrives as a timelineEvent in the
|
|
163
164
|
// merged meta — clears any pending stall watch (stale events ignored).
|
|
164
165
|
noteAgentTimelineEvent(conn.agentId, after?.meta?.timelineEvent);
|
|
@@ -558,6 +559,47 @@ function auditProviderStateTransition(agentId: string, before: AgentCard | null
|
|
|
558
559
|
}
|
|
559
560
|
}
|
|
560
561
|
|
|
562
|
+
function auditRunnerTimelineEvent(agentId: string, timelineEvent: unknown): void {
|
|
563
|
+
if (!isRecord(timelineEvent)) return;
|
|
564
|
+
const metadata = isRecord(timelineEvent.metadata) ? timelineEvent.metadata : {};
|
|
565
|
+
if (metadata.source !== "runner") return;
|
|
566
|
+
const status = stringValue(timelineEvent.status);
|
|
567
|
+
if (!status) return;
|
|
568
|
+
const eventType = stringValue(metadata.eventType) ?? status;
|
|
569
|
+
const timestamp = numberValue(timelineEvent.timestamp) ?? Date.now();
|
|
570
|
+
const id = stringValue(timelineEvent.id) ?? `${eventType}-${timestamp}`;
|
|
571
|
+
const title = stringValue(timelineEvent.title) ?? status.replace(/[._-]+/g, " ");
|
|
572
|
+
const body = stringValue(timelineEvent.body);
|
|
573
|
+
const icon = stringValue(timelineEvent.icon) ?? "ti-activity";
|
|
574
|
+
try {
|
|
575
|
+
const event = createActivityEvent({
|
|
576
|
+
clientId: `runner-timeline-${agentId}-${id}`,
|
|
577
|
+
kind: "state",
|
|
578
|
+
title,
|
|
579
|
+
body,
|
|
580
|
+
meta: agentId,
|
|
581
|
+
icon,
|
|
582
|
+
view: "agents",
|
|
583
|
+
agentId,
|
|
584
|
+
metadata: {
|
|
585
|
+
...metadata,
|
|
586
|
+
eventType,
|
|
587
|
+
timelineStatus: status,
|
|
588
|
+
timelineId: id,
|
|
589
|
+
timelineTimestamp: timestamp,
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
emitRelayEvent({
|
|
593
|
+
type: "activity.created",
|
|
594
|
+
source: "server",
|
|
595
|
+
subject: String(event.id),
|
|
596
|
+
data: event as unknown as Record<string, unknown>,
|
|
597
|
+
});
|
|
598
|
+
} catch {
|
|
599
|
+
// Timeline writes must never block bus status updates.
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
561
603
|
function providerStateFromAgent(agent: AgentCard | null | undefined): Record<string, unknown> | null {
|
|
562
604
|
const value = agent?.meta?.providerState;
|
|
563
605
|
if (!isRecord(value) || typeof value.state !== "string") return null;
|
package/src/config-store.ts
CHANGED
|
@@ -7,13 +7,17 @@ import type {
|
|
|
7
7
|
ConfigHistoryEntry,
|
|
8
8
|
ManagedAgentState,
|
|
9
9
|
ManagedAgentStatus,
|
|
10
|
+
SpawnApprovalMode,
|
|
10
11
|
SpawnPolicy,
|
|
11
12
|
SpawnProvider,
|
|
13
|
+
StewardConfig,
|
|
12
14
|
} from "./types";
|
|
13
15
|
|
|
14
16
|
const CONFIG_HISTORY_LIMIT = 50;
|
|
15
17
|
const SPAWN_POLICY_NAMESPACE = "spawn-policy";
|
|
16
18
|
const AGENT_PROFILE_NAMESPACE = "agent-profile";
|
|
19
|
+
const STEWARD_NAMESPACE = "steward";
|
|
20
|
+
const STEWARD_KEY = "default";
|
|
17
21
|
const VALID_PROVIDERS = ["claude", "codex"] as const;
|
|
18
22
|
const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
|
|
19
23
|
const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
|
|
@@ -390,10 +394,42 @@ function cleanBinding(value: unknown): NonNullable<SpawnPolicy["binding"]> {
|
|
|
390
394
|
};
|
|
391
395
|
}
|
|
392
396
|
|
|
397
|
+
const STEWARD_CONFIG_DEFAULTS: StewardConfig = {
|
|
398
|
+
enabled: false,
|
|
399
|
+
provider: "claude",
|
|
400
|
+
permissionMode: "open",
|
|
401
|
+
keepaliveSeconds: 300,
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
function validateStewardConfig(value: unknown): StewardConfig {
|
|
405
|
+
if (!isRecord(value)) throw new ValidationError("steward config value must be an object");
|
|
406
|
+
const config: StewardConfig = {
|
|
407
|
+
enabled: value.enabled === undefined ? false : cleanBoolean(value.enabled, "enabled"),
|
|
408
|
+
provider: cleanEnum(value.provider, "provider", VALID_PROVIDERS) as SpawnProvider,
|
|
409
|
+
model: cleanString(value.model, "model", { max: 120 }),
|
|
410
|
+
effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
|
|
411
|
+
permissionMode: (value.permissionMode === undefined || value.permissionMode === null
|
|
412
|
+
? "open"
|
|
413
|
+
: cleanEnum(value.permissionMode, "permissionMode", VALID_PERMISSION_MODES)) as SpawnApprovalMode,
|
|
414
|
+
keepaliveSeconds: value.keepaliveSeconds === undefined || value.keepaliveSeconds === null
|
|
415
|
+
? 300
|
|
416
|
+
: cleanNumber(value.keepaliveSeconds, "keepaliveSeconds", { min: 0, max: 2_592_000 }),
|
|
417
|
+
};
|
|
418
|
+
// Reject a provider/model/effort combo the catalog can't resolve before it ever
|
|
419
|
+
// reaches a spawn (same guard as spawn policies).
|
|
420
|
+
try {
|
|
421
|
+
resolveProviderSelection({ provider: config.provider, model: config.model, effort: config.effort });
|
|
422
|
+
} catch (error) {
|
|
423
|
+
throw new ValidationError(error instanceof Error ? error.message : String(error));
|
|
424
|
+
}
|
|
425
|
+
return config;
|
|
426
|
+
}
|
|
427
|
+
|
|
393
428
|
function normalizeValue(namespace: string, key: string, value: unknown): unknown {
|
|
394
429
|
if (value === undefined) throw new ValidationError("value required");
|
|
395
430
|
if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
|
|
396
431
|
if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
|
|
432
|
+
if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
|
|
397
433
|
if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
|
|
398
434
|
return value;
|
|
399
435
|
}
|
|
@@ -485,6 +521,28 @@ function setSpawnPolicy(policy: SpawnPolicy, updatedBy?: string): ConfigEntry<Sp
|
|
|
485
521
|
return setConfig(SPAWN_POLICY_NAMESPACE, policy.name, policy, updatedBy);
|
|
486
522
|
}
|
|
487
523
|
|
|
524
|
+
/** Global steward config, merged over defaults (always returns a usable value). */
|
|
525
|
+
export function getStewardConfig(): StewardConfig {
|
|
526
|
+
const entry = getConfig<StewardConfig>(STEWARD_NAMESPACE, STEWARD_KEY);
|
|
527
|
+
return entry ? { ...STEWARD_CONFIG_DEFAULTS, ...entry.value } : { ...STEWARD_CONFIG_DEFAULTS };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function getStewardConfigEntry(): ConfigEntry<StewardConfig> {
|
|
531
|
+
const entry = getConfig<StewardConfig>(STEWARD_NAMESPACE, STEWARD_KEY);
|
|
532
|
+
return entry ?? {
|
|
533
|
+
namespace: STEWARD_NAMESPACE,
|
|
534
|
+
key: STEWARD_KEY,
|
|
535
|
+
value: { ...STEWARD_CONFIG_DEFAULTS },
|
|
536
|
+
version: 0,
|
|
537
|
+
updatedAt: "default",
|
|
538
|
+
updatedBy: "system",
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function setStewardConfig(value: unknown, updatedBy?: string): ConfigEntry<StewardConfig> {
|
|
543
|
+
return setConfig(STEWARD_NAMESPACE, STEWARD_KEY, value as StewardConfig, updatedBy);
|
|
544
|
+
}
|
|
545
|
+
|
|
488
546
|
function builtInProfileEntry(profile: AgentProfile): ConfigEntry<AgentProfile> {
|
|
489
547
|
return {
|
|
490
548
|
namespace: AGENT_PROFILE_NAMESPACE,
|
package/src/config.ts
CHANGED
|
@@ -24,6 +24,10 @@ export const OFFLINE_PRUNE_MS = envPositiveInt("OFFLINE_PRUNE_MS", DAY_MS); // 2
|
|
|
24
24
|
export const REAP_INTERVAL_MS = envPositiveInt("REAP_INTERVAL_MS", 60_000); // reaper cadence
|
|
25
25
|
export const CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_CLAIM_LEASE_MS", 1_800_000); // 30min claim lease
|
|
26
26
|
export const POOL_CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_POOL_CLAIM_LEASE_MS", STALE_TTL_MS * 3); // pool binding lease
|
|
27
|
+
// Per-repo merge serialization lease — only one base merge may run at a time per
|
|
28
|
+
// repo. Held from when a workspace.merge command is dispatched until it settles
|
|
29
|
+
// (or this TTL expires, in case the orchestrator never reports back).
|
|
30
|
+
export const WORKSPACE_MERGE_LEASE_MS = envPositiveInt("AGENT_RELAY_WORKSPACE_MERGE_LEASE_MS", 900_000); // 15min
|
|
27
31
|
|
|
28
32
|
// Max body size for any POST/PATCH request (64 KiB).
|
|
29
33
|
export const MAX_BODY_BYTES = 64 * 1024;
|
package/src/db.ts
CHANGED
|
@@ -71,7 +71,7 @@ import type {
|
|
|
71
71
|
WorkspaceRecord,
|
|
72
72
|
WorkspaceStatus,
|
|
73
73
|
} from "./types";
|
|
74
|
-
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS } from "./config";
|
|
74
|
+
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "./config";
|
|
75
75
|
|
|
76
76
|
let db: Database;
|
|
77
77
|
const CONTEXT_SNAPSHOT_DEBOUNCE_MS = 60_000;
|
|
@@ -379,6 +379,31 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
379
379
|
CREATE INDEX IF NOT EXISTS idx_workspaces_owner_agent ON workspaces(owner_agent_id);
|
|
380
380
|
CREATE INDEX IF NOT EXISTS idx_workspaces_policy ON workspaces(owner_policy_name);
|
|
381
381
|
|
|
382
|
+
-- Persistent per-repo steward record. Keyed to the repo, not a live agent, so
|
|
383
|
+
-- it survives a full all-agents-offline gap: steward_agent_id goes NULL
|
|
384
|
+
-- (dormant) while last_steward_agent_id preserves continuity, and the row is
|
|
385
|
+
-- re-filled when an agent rejoins the repo. This is the durable backing store
|
|
386
|
+
-- the steward column on workspace rows mirrors for display/maintenance.
|
|
387
|
+
CREATE TABLE IF NOT EXISTS repo_stewards (
|
|
388
|
+
repo_root TEXT PRIMARY KEY,
|
|
389
|
+
steward_agent_id TEXT,
|
|
390
|
+
last_steward_agent_id TEXT,
|
|
391
|
+
elected_at INTEGER,
|
|
392
|
+
updated_at INTEGER NOT NULL
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
-- Per-repo merge serialization lease. Exactly one base merge may be in flight
|
|
396
|
+
-- per repo; a second merge request is rejected until the holder settles or the
|
|
397
|
+
-- lease expires. Atomicity comes from the repo_root PRIMARY KEY + expiry guard.
|
|
398
|
+
CREATE TABLE IF NOT EXISTS workspace_merge_leases (
|
|
399
|
+
repo_root TEXT PRIMARY KEY,
|
|
400
|
+
workspace_id TEXT NOT NULL,
|
|
401
|
+
command_id TEXT,
|
|
402
|
+
holder TEXT,
|
|
403
|
+
acquired_at INTEGER NOT NULL,
|
|
404
|
+
expires_at INTEGER NOT NULL
|
|
405
|
+
);
|
|
406
|
+
|
|
382
407
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
383
408
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
384
409
|
source TEXT NOT NULL,
|
|
@@ -1692,6 +1717,9 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
1692
1717
|
const agent = getAgent(input.id)!;
|
|
1693
1718
|
if (agent.kind === "channel") upsertChannelForAgent(agent);
|
|
1694
1719
|
evaluatePoolBindings();
|
|
1720
|
+
// A (re)joining agent may revive a dormant repo steward — re-elect for the
|
|
1721
|
+
// repos it owns live workspaces in (issue #157, steward survives offline gap).
|
|
1722
|
+
if (agent.status !== "offline") electWorkspaceStewardsForAgent(agent.id);
|
|
1695
1723
|
return agent;
|
|
1696
1724
|
}
|
|
1697
1725
|
|
|
@@ -4140,13 +4168,16 @@ function relayConversationId(message: Message): string | undefined {
|
|
|
4140
4168
|
}
|
|
4141
4169
|
|
|
4142
4170
|
function isCoveredByLaterAgentResponse(message: Message, agentId: string): boolean {
|
|
4171
|
+
// Order by id, not created_at: ids are monotonic insertion order, so this is
|
|
4172
|
+
// robust when a reply lands in the same millisecond as the message it covers
|
|
4173
|
+
// (created_at > … strictly would miss it, leaving the message wrongly pending).
|
|
4143
4174
|
const replies = (db.prepare(`
|
|
4144
4175
|
${MSG_SELECT}
|
|
4145
4176
|
WHERE m.from_agent = ?
|
|
4146
|
-
AND m.
|
|
4147
|
-
ORDER BY m.
|
|
4177
|
+
AND m.id > ?
|
|
4178
|
+
ORDER BY m.id ASC
|
|
4148
4179
|
LIMIT 200
|
|
4149
|
-
`).all(agentId, message.
|
|
4180
|
+
`).all(agentId, message.id) as any[]).map(rowToMessage);
|
|
4150
4181
|
|
|
4151
4182
|
const conversationId = relayConversationId(message);
|
|
4152
4183
|
return replies.some((reply) => {
|
|
@@ -5108,31 +5139,203 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
|
|
|
5108
5139
|
return getWorkspace(id);
|
|
5109
5140
|
}
|
|
5110
5141
|
|
|
5142
|
+
// Workspace statuses that count as "live" for stewardship — an agent owning one
|
|
5143
|
+
// of these is a candidate steward; the repo is worth coordinating.
|
|
5144
|
+
const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
|
|
5145
|
+
|
|
5146
|
+
export interface RepoStewardRecord {
|
|
5147
|
+
repoRoot: string;
|
|
5148
|
+
stewardAgentId?: string;
|
|
5149
|
+
lastStewardAgentId?: string;
|
|
5150
|
+
electedAt?: number;
|
|
5151
|
+
updatedAt: number;
|
|
5152
|
+
}
|
|
5153
|
+
|
|
5154
|
+
function rowToRepoSteward(row: any): RepoStewardRecord {
|
|
5155
|
+
return {
|
|
5156
|
+
repoRoot: row.repo_root,
|
|
5157
|
+
stewardAgentId: row.steward_agent_id ?? undefined,
|
|
5158
|
+
lastStewardAgentId: row.last_steward_agent_id ?? undefined,
|
|
5159
|
+
electedAt: row.elected_at ?? undefined,
|
|
5160
|
+
updatedAt: row.updated_at,
|
|
5161
|
+
};
|
|
5162
|
+
}
|
|
5163
|
+
|
|
5164
|
+
export function getRepoSteward(repoRoot: string): RepoStewardRecord | null {
|
|
5165
|
+
const row = db.prepare("SELECT * FROM repo_stewards WHERE repo_root = ?").get(repoRoot) as any;
|
|
5166
|
+
return row ? rowToRepoSteward(row) : null;
|
|
5167
|
+
}
|
|
5168
|
+
|
|
5169
|
+
export function listRepoStewards(): RepoStewardRecord[] {
|
|
5170
|
+
return (db.prepare("SELECT * FROM repo_stewards ORDER BY updated_at DESC").all() as any[]).map(rowToRepoSteward);
|
|
5171
|
+
}
|
|
5172
|
+
|
|
5173
|
+
// Persist the elected steward for a repo. The row is never deleted, so a repo's
|
|
5174
|
+
// stewardship survives a full all-agents-offline gap (steward goes NULL/dormant,
|
|
5175
|
+
// last_steward_agent_id keeps continuity) and resumes on the next agent join.
|
|
5176
|
+
function upsertRepoSteward(repoRoot: string, steward: string | null, now: number): void {
|
|
5177
|
+
db.prepare(`
|
|
5178
|
+
INSERT INTO repo_stewards (repo_root, steward_agent_id, last_steward_agent_id, elected_at, updated_at)
|
|
5179
|
+
VALUES ($repoRoot, $steward, $steward, $electedAt, $now)
|
|
5180
|
+
ON CONFLICT(repo_root) DO UPDATE SET
|
|
5181
|
+
steward_agent_id = $steward,
|
|
5182
|
+
last_steward_agent_id = coalesce($steward, repo_stewards.last_steward_agent_id),
|
|
5183
|
+
elected_at = CASE
|
|
5184
|
+
WHEN $steward IS NOT NULL AND $steward IS NOT repo_stewards.steward_agent_id THEN $now
|
|
5185
|
+
ELSE repo_stewards.elected_at
|
|
5186
|
+
END,
|
|
5187
|
+
updated_at = $now
|
|
5188
|
+
`).run({ $repoRoot: repoRoot, $steward: steward, $electedAt: steward ? now : null, $now: now });
|
|
5189
|
+
}
|
|
5190
|
+
|
|
5111
5191
|
function electWorkspaceStewards(repoRoot?: string): void {
|
|
5112
5192
|
const params: string[] = repoRoot ? [repoRoot] : [];
|
|
5113
5193
|
const repoRows = db.prepare(`
|
|
5114
5194
|
SELECT DISTINCT repo_root FROM workspaces
|
|
5115
|
-
WHERE status IN (
|
|
5195
|
+
WHERE status IN (${STEWARD_LIVE_STATUSES})
|
|
5116
5196
|
${repoRoot ? "AND repo_root = ?" : ""}
|
|
5117
5197
|
`).all(...params) as Array<{ repo_root: string }>;
|
|
5198
|
+
const now = Date.now();
|
|
5118
5199
|
for (const row of repoRows) {
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5200
|
+
// Candidate pool: owners of live workspaces in this repo who are online,
|
|
5201
|
+
// oldest first. A steward must be an online agent actively in the repo — an
|
|
5202
|
+
// offline agent can't coordinate, so it is never elected (the old bug).
|
|
5203
|
+
const pool = (db.prepare(`
|
|
5204
|
+
SELECT w.owner_agent_id AS id, MIN(w.created_at) AS created_at
|
|
5205
|
+
FROM workspaces w JOIN agents a ON a.id = w.owner_agent_id
|
|
5206
|
+
WHERE w.repo_root = ? AND w.owner_agent_id IS NOT NULL
|
|
5207
|
+
AND a.status != 'offline' AND w.status IN (${STEWARD_LIVE_STATUSES})
|
|
5208
|
+
GROUP BY w.owner_agent_id
|
|
5209
|
+
ORDER BY created_at ASC
|
|
5210
|
+
`).all(row.repo_root) as Array<{ id: string }>).map((r) => r.id);
|
|
5211
|
+
|
|
5212
|
+
// Keep the current steward if it is still in the pool (stable election);
|
|
5213
|
+
// otherwise promote the oldest online owner, else go dormant (null).
|
|
5214
|
+
const current = getRepoSteward(row.repo_root)?.stewardAgentId ?? null;
|
|
5215
|
+
const steward = (current && pool.includes(current) ? current : pool[0]) ?? null;
|
|
5216
|
+
|
|
5217
|
+
upsertRepoSteward(row.repo_root, steward, now);
|
|
5218
|
+
// Mirror onto live workspace rows only when the steward actually changed, so
|
|
5219
|
+
// re-elections don't churn updated_at and reset the auto-abandon clock for a
|
|
5220
|
+
// dormant repo (a stranded review_requested must still age out).
|
|
5221
|
+
if (steward !== current) {
|
|
5222
|
+
db.prepare(`UPDATE workspaces SET steward_agent_id = ?, updated_at = ? WHERE repo_root = ? AND status IN (${STEWARD_LIVE_STATUSES})`)
|
|
5223
|
+
.run(steward, now, row.repo_root);
|
|
5224
|
+
}
|
|
5225
|
+
}
|
|
5226
|
+
}
|
|
5227
|
+
|
|
5228
|
+
// Public re-election trigger that does not change any workspace status — used by
|
|
5229
|
+
// maintenance to revive a dormant steward (e.g. on the next agent join) before
|
|
5230
|
+
// deciding whether a stranded worktree needs escalation.
|
|
5231
|
+
export function reelectRepoSteward(repoRoot: string): void {
|
|
5232
|
+
electWorkspaceStewards(repoRoot);
|
|
5233
|
+
}
|
|
5234
|
+
|
|
5235
|
+
// Merge a metadata patch into a workspace WITHOUT bumping updated_at or running a
|
|
5236
|
+
// steward election. For maintenance bookkeeping (stranded/escalation markers)
|
|
5237
|
+
// that must not disturb age-based GC timers. undefined values delete keys.
|
|
5238
|
+
export function patchWorkspaceMetadata(id: string, patch: Record<string, unknown>): WorkspaceRecord | null {
|
|
5239
|
+
const existing = getWorkspace(id);
|
|
5240
|
+
if (!existing) return null;
|
|
5241
|
+
const next = { ...existing.metadata };
|
|
5242
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
5243
|
+
if (v === undefined) delete next[k];
|
|
5244
|
+
else next[k] = v;
|
|
5135
5245
|
}
|
|
5246
|
+
db.prepare("UPDATE workspaces SET metadata = ? WHERE id = ?").run(JSON.stringify(next), id);
|
|
5247
|
+
return getWorkspace(id);
|
|
5248
|
+
}
|
|
5249
|
+
|
|
5250
|
+
// Re-elect stewards for every repo where an agent owns a live workspace. Called
|
|
5251
|
+
// when an agent (re)registers so a dormant repo regains a steward on rejoin
|
|
5252
|
+
// without a full unscoped sweep.
|
|
5253
|
+
function electWorkspaceStewardsForAgent(agentId: string): void {
|
|
5254
|
+
const repos = db.prepare(`
|
|
5255
|
+
SELECT DISTINCT repo_root FROM workspaces
|
|
5256
|
+
WHERE owner_agent_id = ? AND status IN (${STEWARD_LIVE_STATUSES})
|
|
5257
|
+
`).all(agentId) as Array<{ repo_root: string }>;
|
|
5258
|
+
for (const r of repos) electWorkspaceStewards(r.repo_root);
|
|
5259
|
+
}
|
|
5260
|
+
|
|
5261
|
+
// --- Per-repo merge serialization lease (issue #157) -----------------------
|
|
5262
|
+
|
|
5263
|
+
export interface MergeLeaseRecord {
|
|
5264
|
+
repoRoot: string;
|
|
5265
|
+
workspaceId: string;
|
|
5266
|
+
commandId?: string;
|
|
5267
|
+
holder?: string;
|
|
5268
|
+
acquiredAt: number;
|
|
5269
|
+
expiresAt: number;
|
|
5270
|
+
}
|
|
5271
|
+
|
|
5272
|
+
function rowToMergeLease(row: any): MergeLeaseRecord {
|
|
5273
|
+
return {
|
|
5274
|
+
repoRoot: row.repo_root,
|
|
5275
|
+
workspaceId: row.workspace_id,
|
|
5276
|
+
commandId: row.command_id ?? undefined,
|
|
5277
|
+
holder: row.holder ?? undefined,
|
|
5278
|
+
acquiredAt: row.acquired_at,
|
|
5279
|
+
expiresAt: row.expires_at,
|
|
5280
|
+
};
|
|
5281
|
+
}
|
|
5282
|
+
|
|
5283
|
+
export function getMergeLease(repoRoot: string): MergeLeaseRecord | null {
|
|
5284
|
+
const row = db.prepare("SELECT * FROM workspace_merge_leases WHERE repo_root = ?").get(repoRoot) as any;
|
|
5285
|
+
return row ? rowToMergeLease(row) : null;
|
|
5286
|
+
}
|
|
5287
|
+
|
|
5288
|
+
export function listMergeLeases(): MergeLeaseRecord[] {
|
|
5289
|
+
return (db.prepare("SELECT * FROM workspace_merge_leases ORDER BY acquired_at DESC").all() as any[]).map(rowToMergeLease);
|
|
5290
|
+
}
|
|
5291
|
+
|
|
5292
|
+
export function releaseExpiredMergeLeases(now: number = Date.now()): string[] {
|
|
5293
|
+
const expired = db.prepare("SELECT repo_root FROM workspace_merge_leases WHERE expires_at <= ?").all(now) as Array<{ repo_root: string }>;
|
|
5294
|
+
if (!expired.length) return [];
|
|
5295
|
+
db.prepare("DELETE FROM workspace_merge_leases WHERE expires_at <= ?").run(now);
|
|
5296
|
+
return expired.map((r) => r.repo_root);
|
|
5297
|
+
}
|
|
5298
|
+
|
|
5299
|
+
// Atomically acquire the per-repo merge lease. Succeeds if no live lease is held
|
|
5300
|
+
// for the repo (or the existing one has expired). Serialized via db.transaction
|
|
5301
|
+
// so two concurrent merge requests for the same repo can't both win.
|
|
5302
|
+
export function acquireMergeLease(
|
|
5303
|
+
repoRoot: string,
|
|
5304
|
+
workspaceId: string,
|
|
5305
|
+
holder?: string,
|
|
5306
|
+
): { ok: true; lease: MergeLeaseRecord } | { ok: false; lease: MergeLeaseRecord } {
|
|
5307
|
+
return db.transaction(() => {
|
|
5308
|
+
const now = Date.now();
|
|
5309
|
+
const existing = getMergeLease(repoRoot);
|
|
5310
|
+
if (existing && existing.expiresAt > now) return { ok: false as const, lease: existing };
|
|
5311
|
+
const expiresAt = now + WORKSPACE_MERGE_LEASE_MS;
|
|
5312
|
+
db.prepare(`
|
|
5313
|
+
INSERT INTO workspace_merge_leases (repo_root, workspace_id, command_id, holder, acquired_at, expires_at)
|
|
5314
|
+
VALUES (?, ?, NULL, ?, ?, ?)
|
|
5315
|
+
ON CONFLICT(repo_root) DO UPDATE SET
|
|
5316
|
+
workspace_id = excluded.workspace_id, command_id = NULL, holder = excluded.holder,
|
|
5317
|
+
acquired_at = excluded.acquired_at, expires_at = excluded.expires_at
|
|
5318
|
+
`).run(repoRoot, workspaceId, holder ?? null, now, expiresAt);
|
|
5319
|
+
return { ok: true as const, lease: getMergeLease(repoRoot)! };
|
|
5320
|
+
})();
|
|
5321
|
+
}
|
|
5322
|
+
|
|
5323
|
+
// Attach the dispatched command id to a held lease so it can be released by
|
|
5324
|
+
// command id when the merge settles.
|
|
5325
|
+
export function setMergeLeaseCommand(repoRoot: string, commandId: string): void {
|
|
5326
|
+
db.prepare("UPDATE workspace_merge_leases SET command_id = ? WHERE repo_root = ?").run(commandId, repoRoot);
|
|
5327
|
+
}
|
|
5328
|
+
|
|
5329
|
+
// Release a merge lease. Guard by commandId/workspaceId when known so a stale
|
|
5330
|
+
// release can't drop a newer lease for the same repo.
|
|
5331
|
+
export function releaseMergeLease(opts: { repoRoot?: string; commandId?: string; workspaceId?: string }): boolean {
|
|
5332
|
+
const where: string[] = [];
|
|
5333
|
+
const params: string[] = [];
|
|
5334
|
+
if (opts.repoRoot) { where.push("repo_root = ?"); params.push(opts.repoRoot); }
|
|
5335
|
+
if (opts.commandId) { where.push("command_id = ?"); params.push(opts.commandId); }
|
|
5336
|
+
if (opts.workspaceId) { where.push("workspace_id = ?"); params.push(opts.workspaceId); }
|
|
5337
|
+
if (!where.length) return false;
|
|
5338
|
+
return db.prepare(`DELETE FROM workspace_merge_leases WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
|
|
5136
5339
|
}
|
|
5137
5340
|
|
|
5138
5341
|
export function deleteOrchestrator(id: string): boolean {
|