agent-relay-server 0.117.1 → 0.118.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.117.1",
4
+ "version": "0.118.1",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -0,0 +1,36 @@
1
+ import { getArtifactStorage } from "./artifact-storage";
2
+ import { bytesToStream } from "./http-body";
3
+ import { createArtifact, upsertArtifactBlob } from "./db/artifacts";
4
+ import type { ArtifactKind } from "./types";
5
+
6
+ /**
7
+ * Persist a UTF-8 text blob as a relay artifact and return its id, composing the
8
+ * same store→blob→record pipeline the MCP upload tool uses. Used by the land-gate
9
+ * pipeline (#902) to stream a failing gate's FULL output to an artifact so the
10
+ * worker/coordinator notification carries only the tail + this ref — never the
11
+ * flood of full output.
12
+ */
13
+ export async function storeTextArtifact(input: {
14
+ text: string;
15
+ filename: string;
16
+ createdBy: string;
17
+ kind?: ArtifactKind;
18
+ mediaType?: string;
19
+ metadata?: Record<string, unknown>;
20
+ }): Promise<{ id: string; size: number }> {
21
+ const bytes = new TextEncoder().encode(input.text);
22
+ const mediaType = input.mediaType ?? "text/plain; charset=utf-8";
23
+ const storage = getArtifactStorage();
24
+ const stored = await storage.store(bytesToStream(bytes), { mediaType, size: bytes.byteLength });
25
+ upsertArtifactBlob({ digest: stored.digest, storageUri: stored.storageUri, mediaType, size: stored.size });
26
+ const artifact = createArtifact({
27
+ blobDigest: stored.digest,
28
+ mediaType,
29
+ kind: input.kind ?? "document",
30
+ filename: input.filename,
31
+ size: stored.size,
32
+ createdBy: input.createdBy,
33
+ metadata: input.metadata,
34
+ });
35
+ return { id: artifact.id, size: stored.size };
36
+ }
@@ -0,0 +1,136 @@
1
+ // #901 — resolver shim + configurable per-type work-shape config for internal auto-spawns.
2
+ // PURE ADDITIONS — zero behavior change at existing call sites. The dispatch brain
3
+ // (dispatchTask) is reused verbatim; this module only drives it with the configured
4
+ // work-shape for each InternalSpawnType and the #901 degrade-never-refuse flag.
5
+ import { getConfig, setConfig } from "./config-store";
6
+ import { dispatchTask, type DispatchDecision, type DispatchPin } from "./task-dispatcher";
7
+ import {
8
+ INTERNAL_SPAWN_TYPES,
9
+ TASK_DISPATCH_COMPLEXITIES,
10
+ TASK_DISPATCH_CRITICALITIES,
11
+ TASK_DISPATCH_FOOTPRINTS,
12
+ TASK_DISPATCH_TYPES,
13
+ isRecord,
14
+ } from "agent-relay-sdk";
15
+ import type {
16
+ AutoSpawnDispatchConfig,
17
+ InternalSpawnType,
18
+ TaskDispatch,
19
+ TaskDispatchCriticality,
20
+ WorkShapeProfile,
21
+ } from "agent-relay-sdk";
22
+
23
+ export const AUTO_SPAWN_DISPATCH_NAMESPACE = "auto-spawn-dispatch";
24
+ export const AUTO_SPAWN_DISPATCH_KEY = "default";
25
+
26
+ // Re-export so callers only need this module.
27
+ export type { InternalSpawnType } from "agent-relay-sdk";
28
+
29
+ const ALL_AUTO_PROFILE: WorkShapeProfile = {
30
+ complexity: "auto",
31
+ type: "auto",
32
+ contextFootprint: "auto",
33
+ criticality: "auto",
34
+ };
35
+
36
+ export const DEFAULT_AUTO_SPAWN_DISPATCH_CONFIG: AutoSpawnDispatchConfig = {
37
+ schemaVersion: 1,
38
+ profiles: {
39
+ "landing-steward": { ...ALL_AUTO_PROFILE },
40
+ "file-size-ratchet-heal": { ...ALL_AUTO_PROFILE },
41
+ "spawn-smoke": { ...ALL_AUTO_PROFILE },
42
+ },
43
+ };
44
+
45
+ // Built-in criticality for each spawn type when the profile's criticality dimension is "auto".
46
+ // landing-steward is high because it is the critical-infra auto-land path; the degrade-never-refuse
47
+ // safety (mustResolveAvailable) is therefore always on for it unless the operator overrides.
48
+ const BUILT_IN_CRITICALITY: Record<InternalSpawnType, TaskDispatchCriticality> = {
49
+ "landing-steward": "high",
50
+ "file-size-ratchet-heal": "normal",
51
+ "spawn-smoke": "low",
52
+ };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Config store
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export function getAutoSpawnDispatchConfig(): AutoSpawnDispatchConfig {
59
+ const entry = getConfig<AutoSpawnDispatchConfig>(AUTO_SPAWN_DISPATCH_NAMESPACE, AUTO_SPAWN_DISPATCH_KEY);
60
+ return entry ? normalizeAutoSpawnDispatchConfig(entry.value) : { ...DEFAULT_AUTO_SPAWN_DISPATCH_CONFIG };
61
+ }
62
+
63
+ export function setAutoSpawnDispatchConfig(value: unknown, opts: { updatedBy?: string } = {}): AutoSpawnDispatchConfig {
64
+ const normalized = normalizeAutoSpawnDispatchConfig(value);
65
+ const current = getAutoSpawnDispatchConfig();
66
+ if (JSON.stringify(normalized) === JSON.stringify(current)) return normalized;
67
+ setConfig(AUTO_SPAWN_DISPATCH_NAMESPACE, AUTO_SPAWN_DISPATCH_KEY, normalized, opts.updatedBy);
68
+ return normalized;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Normalizer — coerces arbitrary input into a valid AutoSpawnDispatchConfig.
73
+ // "auto" is the safe default for every dimension, so a partial config is always valid.
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export function normalizeAutoSpawnDispatchConfig(value: unknown): AutoSpawnDispatchConfig {
77
+ const input = isRecord(value) ? value : {};
78
+ const rawProfiles = isRecord(input.profiles) ? input.profiles : {};
79
+ const profiles = {} as AutoSpawnDispatchConfig["profiles"];
80
+ for (const type of INTERNAL_SPAWN_TYPES) {
81
+ const raw = isRecord(rawProfiles[type]) ? rawProfiles[type] : {};
82
+ profiles[type] = normalizeWorkShapeProfile(raw);
83
+ }
84
+ return { schemaVersion: 1, profiles };
85
+ }
86
+
87
+ export function validateAutoSpawnDispatchConfig(value: unknown): AutoSpawnDispatchConfig {
88
+ return normalizeAutoSpawnDispatchConfig(value);
89
+ }
90
+
91
+ function normalizeWorkShapeProfile(raw: Record<string, unknown>): WorkShapeProfile {
92
+ return {
93
+ complexity: normalizeAutoDim(raw.complexity, TASK_DISPATCH_COMPLEXITIES),
94
+ type: normalizeAutoDim(raw.type, TASK_DISPATCH_TYPES),
95
+ contextFootprint: normalizeAutoDim(raw.contextFootprint, TASK_DISPATCH_FOOTPRINTS),
96
+ criticality: normalizeAutoDim(raw.criticality, TASK_DISPATCH_CRITICALITIES),
97
+ };
98
+ }
99
+
100
+ function normalizeAutoDim<T extends string>(value: unknown, allowed: readonly T[]): "auto" | T {
101
+ if (value === "auto") return "auto";
102
+ if (typeof value === "string" && (allowed as readonly string[]).includes(value)) return value as T;
103
+ return "auto";
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Resolver shim
108
+ // ---------------------------------------------------------------------------
109
+
110
+ function effectiveCriticality(type: InternalSpawnType, profile: WorkShapeProfile): TaskDispatchCriticality {
111
+ return profile.criticality === "auto" ? BUILT_IN_CRITICALITY[type] : profile.criticality;
112
+ }
113
+
114
+ /**
115
+ * Resolve a concrete spawn target for an internal auto-spawn. Reads the configured work-shape
116
+ * for `type`, maps each "auto" dimension → undefined (wildcard), and calls dispatchTask.
117
+ * Returns the full DispatchDecision so callers can log WHY and surface the rationale.
118
+ *
119
+ * For high-criticality types (landing-steward by default) the degrade-never-refuse flag is set,
120
+ * guaranteeing a provider is returned even when all quota pools are blocked.
121
+ */
122
+ export function resolveInternalSpawnTarget(type: InternalSpawnType, pin?: DispatchPin): DispatchDecision {
123
+ const config = getAutoSpawnDispatchConfig();
124
+ const profile = config.profiles[type];
125
+
126
+ // Map "auto" → undefined; a concrete value is passed through to constrain the dispatch match.
127
+ const shape: TaskDispatch = {
128
+ ...(profile.complexity !== "auto" ? { complexity: profile.complexity } : {}),
129
+ ...(profile.type !== "auto" ? { type: profile.type } : {}),
130
+ ...(profile.contextFootprint !== "auto" ? { contextFootprint: profile.contextFootprint } : {}),
131
+ ...(profile.criticality !== "auto" ? { criticality: profile.criticality } : {}),
132
+ };
133
+
134
+ const mustResolveAvailable = effectiveCriticality(type, profile) === "high";
135
+ return dispatchTask(shape, pin ?? {}, { mustResolveAvailable });
136
+ }
@@ -1,4 +1,4 @@
1
- import { errMessage, type BaseWorktreeSyncResult } from "agent-relay-sdk";
1
+ import { errMessage, type BaseWorktreeSyncResult, type LandGateRunResult } from "agent-relay-sdk";
2
2
  import { emitRelayEvent } from "./events";
3
3
  import { routeNotification, INFORMATIONAL_TTL_MS } from "./notification-router";
4
4
  import { registerDefaultAudienceRules, audiencePredicates } from "./notification-rules";
@@ -357,6 +357,132 @@ export function notifyBranchLandFailed(input: {
357
357
  });
358
358
  }
359
359
 
360
+ function gateOutcomeLabel(gate: LandGateRunResult): string {
361
+ if (gate.timedOut) return "timed out";
362
+ if (gate.exitCode === null) return "could not run";
363
+ return `exited ${gate.exitCode}`;
364
+ }
365
+
366
+ /**
367
+ * #902 — a REQUIRED land gate failed against the candidate merged tree, so the land was
368
+ * aborted WITHOUT advancing `main` (ref unchanged). This is a CORRECTNESS gate, orthogonal
369
+ * to merge conflicts: it NEVER routes through the steward/conflict path. Wake the worker
370
+ * (owner) AND its coordinator with the failing gate name + a bounded output TAIL + the relay
371
+ * artifact ref holding the full output, so the worker fixes the gate and re-lands cleanly.
372
+ * Durable (ttl null): the owner is parked idle after `ready` and must still learn why its
373
+ * land bounced. The full gate output lives in the artifact — the notice carries only the tail,
374
+ * so a noisy gate never floods relay.
375
+ */
376
+ export function notifyLandGateFailed(input: {
377
+ workspace: Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
378
+ gate: LandGateRunResult;
379
+ /** Relay artifact id holding the full gate output, when the upload succeeded. */
380
+ artifactId?: string;
381
+ }): void {
382
+ const { workspace, gate } = input;
383
+ const base = workspace.baseRef ?? "base";
384
+ const owner = workspace.ownerAgentId;
385
+ const coordinator = resolveCoordinatorAgentId(owner ?? undefined);
386
+ const fallback = stewardFallbackTarget();
387
+ const branchLabel = workspace.branch ? `\`${workspace.branch}\`` : "Your branch";
388
+ const reasonLabel = gateOutcomeLabel(gate);
389
+ const artifactLabel = input.artifactId ? ` Full output: artifact \`${input.artifactId}\`.` : "";
390
+ const tail = gate.outputTail?.trim() ? `\n\n\`\`\`\n${gate.outputTail.trim()}\n\`\`\`` : "";
391
+ const body = `🚦 ${branchLabel} did NOT land: required land gate \`${gate.name}\` ${reasonLabel} against the candidate merge into \`${base}\`. \`${base}\` was NOT advanced — fix the gate and re-land (\`agent-relay workspace ready\`).${artifactLabel}${tail}`;
392
+
393
+ const payload = {
394
+ kind: "workspace.land-gate-failed",
395
+ workspaceId: workspace.id,
396
+ repoRoot: workspace.repoRoot,
397
+ branch: workspace.branch,
398
+ base,
399
+ author: owner,
400
+ gateName: gate.name,
401
+ gateCommand: gate.command,
402
+ exitCode: gate.exitCode,
403
+ timedOut: gate.timedOut,
404
+ durationMs: gate.durationMs,
405
+ artifactId: input.artifactId,
406
+ };
407
+
408
+ emitRelayEvent({ type: "workspace.land_gate_failed", source: "server", subject: workspace.id, data: payload });
409
+
410
+ createActivityEvent({
411
+ clientId: `workspace-land-gate-failed-${workspace.id}-${Date.now()}`,
412
+ kind: "state",
413
+ title: "Land blocked by gate failure",
414
+ body: `${workspace.branch ?? workspace.id} → ${base}: required gate "${gate.name}" ${reasonLabel} — ${base} not advanced`,
415
+ meta: workspace.branch ?? workspace.id,
416
+ icon: "ti-alert-octagon",
417
+ view: "orchestrators",
418
+ metadata: { source: "server", maintenanceJobId: "workspace-land-gate", workspaceId: workspace.id, repoRoot: workspace.repoRoot, gateName: gate.name, ...(input.artifactId ? { artifactId: input.artifactId } : {}) },
419
+ });
420
+
421
+ // Worker + coordinator (spec). Fall back to the configured steward target only when no
422
+ // coordinator resolves (a teamless land) so the bounce always reaches a human/agent.
423
+ const recipients = [...new Set([owner, coordinator ?? fallback].filter((r): r is string => Boolean(r)))];
424
+ if (!recipients.length) return;
425
+ routeNotification({
426
+ type: "workspace.land_gate_failed",
427
+ scope: { author: owner, parent: coordinator ?? undefined, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
428
+ recipients,
429
+ ttlMs: null,
430
+ message: {
431
+ subject: `Land blocked: gate "${gate.name}" failed`,
432
+ body,
433
+ payload: { ...payload, notification: "land-gate-failed" },
434
+ replyExpected: false,
435
+ },
436
+ });
437
+ }
438
+
439
+ /**
440
+ * #902 — OPTIONAL land gates that failed but did NOT block the land (the land succeeded and
441
+ * `main` advanced). Surface a single low-priority warning to the worker with the gate names
442
+ * + the artifact ref holding the combined output, so the warn signal isn't lost but doesn't
443
+ * bounce a successful land. Informational TTL: a stale warning shouldn't wake a late joiner.
444
+ */
445
+ export function notifyLandGateWarnings(input: {
446
+ workspace: Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
447
+ warnings: LandGateRunResult[];
448
+ artifactId?: string;
449
+ }): void {
450
+ const { workspace, warnings } = input;
451
+ if (warnings.length === 0) return;
452
+ const base = workspace.baseRef ?? "base";
453
+ const owner = workspace.ownerAgentId;
454
+ const branchLabel = workspace.branch ? `\`${workspace.branch}\`` : "Your branch";
455
+ const names = warnings.map((w) => `\`${w.name}\` (${gateOutcomeLabel(w)})`).join(", ");
456
+ const artifactLabel = input.artifactId ? ` Full output: artifact \`${input.artifactId}\`.` : "";
457
+
458
+ const payload = {
459
+ kind: "workspace.land-gate-warning",
460
+ workspaceId: workspace.id,
461
+ repoRoot: workspace.repoRoot,
462
+ branch: workspace.branch,
463
+ base,
464
+ author: owner,
465
+ gateNames: warnings.map((w) => w.name),
466
+ artifactId: input.artifactId,
467
+ };
468
+
469
+ emitRelayEvent({ type: "workspace.land_gate_warning", source: "server", subject: workspace.id, data: payload });
470
+
471
+ if (!owner) return;
472
+ routeNotification({
473
+ type: "workspace.land_gate_warning",
474
+ scope: { author: owner, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
475
+ recipients: [owner],
476
+ ttlMs: INFORMATIONAL_TTL_MS,
477
+ message: {
478
+ subject: `Land gate warning${warnings.length > 1 ? "s" : ""} (landed anyway)`,
479
+ body: `⚠️ ${branchLabel} landed to \`${base}\`, but optional land gate${warnings.length > 1 ? "s" : ""} failed: ${names}. Not blocking — review when convenient.${artifactLabel}`,
480
+ payload: { ...payload, notification: "land-gate-warning" },
481
+ replyExpected: false,
482
+ },
483
+ });
484
+ }
485
+
360
486
  /**
361
487
  * Steward-handoff awareness wake (#641/#642). When the relay can't auto-merge a branch and
362
488
  * hands the workspace to a steward, the two ends used to be mutually blind: the branch agent
@@ -68,5 +68,9 @@ export function runDbMaintenance(options: { vacuum?: boolean } = {}): {
68
68
  }
69
69
 
70
70
  // Shared error types thrown across query domains and unwrapped at call sites.
71
- export class ValidationError extends Error {}
71
+ // `ValidationError` now lives in the SDK (agent-relay-sdk) as the single canonical
72
+ // home shared with the orchestrator package; re-exported here so the ~120 server
73
+ // call sites (and #892's `toThrow(ValidationError)` tests) stay byte-unchanged and
74
+ // keep the same class identity.
75
+ export { ValidationError } from "agent-relay-sdk";
72
76
  export class ClaimError extends Error {}
@@ -26,8 +26,8 @@ import { seedBundledProvisioningAssets, seedSharedCallmuxProvisioningAssets } fr
26
26
  import { provisioningAssetsColumnNames, provisioningAssetsNeedsRebuild, rebuildProvisioningAssetsTable } from "./provisioning-assets-migration.ts";
27
27
  import { normalizeReactionEmoji } from "./mappers.ts";
28
28
  import { normalizeProviderQuotaAccountKeys } from "./provider-quotas.ts";
29
- import { projectRootsMissingIssueRepo, setProjectIssueRepo } from "./projects.ts";
30
- import { repoFromGitRemote } from "../services/git-remote.ts";
29
+ import { projectRootsMissingIssueRepo, projectRootsMissingRemoteUrl, setProjectIssueRepo, setProjectRootRemoteUrl } from "./projects.ts";
30
+ import { remoteUrlFromGitRemote, repoFromGitRemote } from "../services/git-remote.ts";
31
31
  import type {
32
32
  AgentCard,
33
33
  ActivityEvent,
@@ -136,6 +136,17 @@ function backfillProjectIssueRepos(): void {
136
136
  }
137
137
  }
138
138
 
139
+ function backfillProjectRootRemoteUrls(): void {
140
+ for (const row of projectRootsMissingRemoteUrl()) {
141
+ try {
142
+ const remoteUrl = remoteUrlFromGitRemote(row.rootPath);
143
+ if (remoteUrl) setProjectRootRemoteUrl(row.projectId, row.rootPath, remoteUrl, { onlyIfNull: true });
144
+ } catch {
145
+ // Best-effort startup repair: a missing root or unavailable git must not block Relay.
146
+ }
147
+ }
148
+ }
149
+
139
150
 
140
151
  // Incremental schema migrations + one-shot backfills, applied after the base DDL
141
152
  // in initDb(). Ordering matters: each block guards on the current column/table
@@ -512,7 +523,16 @@ export function applyMigrations(): void {
512
523
  getDb().run("ALTER TABLE projects ADD COLUMN anchor_key TEXT");
513
524
  }
514
525
  getDb().run("CREATE INDEX IF NOT EXISTS idx_projects_scope_anchor ON projects(scope, anchor_key)");
526
+ // Migration: project_roots.remote_url/ref (#410) — clone/sync manifest metadata.
527
+ const projectRootCols = (getDb().query("PRAGMA table_info(project_roots)").all() as any[]).map((c: any) => c.name);
528
+ if (!projectRootCols.includes("remote_url")) {
529
+ getDb().run("ALTER TABLE project_roots ADD COLUMN remote_url TEXT");
530
+ }
531
+ if (!projectRootCols.includes("ref")) {
532
+ getDb().run("ALTER TABLE project_roots ADD COLUMN ref TEXT");
533
+ }
515
534
  backfillProjectIssueRepos();
535
+ backfillProjectRootRemoteUrls();
516
536
 
517
537
  // Migration: orchestrators.api_url
518
538
  const orchCols = getDb().query("PRAGMA table_info(orchestrators)").all() as any[];
@@ -25,6 +25,8 @@ interface ProjectRow {
25
25
  interface ProjectRootRow {
26
26
  project_id: string;
27
27
  root_path: string;
28
+ remote_url: string | null;
29
+ ref: string | null;
28
30
  created_at: number;
29
31
  }
30
32
 
@@ -49,7 +51,13 @@ function rowToProject(row: ProjectRow): Project {
49
51
  }
50
52
 
51
53
  function rowToRoot(row: ProjectRootRow): ProjectRoot {
52
- return { projectId: row.project_id, rootPath: row.root_path, createdAt: row.created_at };
54
+ return {
55
+ projectId: row.project_id,
56
+ rootPath: row.root_path,
57
+ ...(row.remote_url ? { remoteUrl: row.remote_url } : {}),
58
+ ...(row.ref ? { ref: row.ref } : {}),
59
+ createdAt: row.created_at,
60
+ };
53
61
  }
54
62
 
55
63
  export function getProject(id: string): Project | null {
@@ -103,6 +111,26 @@ export function projectRootsMissingIssueRepo(): Array<{ projectId: string; rootP
103
111
  `).all() as Array<{ projectId: string; rootPath: string }>;
104
112
  }
105
113
 
114
+ export function projectRootsMissingRemoteUrl(): Array<{ projectId: string; rootPath: string }> {
115
+ return getDb().query(`
116
+ SELECT project_id AS projectId, root_path AS rootPath
117
+ FROM project_roots
118
+ WHERE remote_url IS NULL OR trim(remote_url) = ''
119
+ ORDER BY project_id ASC, root_path ASC
120
+ `).all() as Array<{ projectId: string; rootPath: string }>;
121
+ }
122
+
123
+ export function setProjectRootRemoteUrl(projectId: string, rootPath: string, remoteUrl: string | null, opts: { onlyIfNull?: boolean } = {}): ProjectRoot[] {
124
+ const value = remoteUrl?.trim() || null;
125
+ const current = getDb()
126
+ .query("SELECT remote_url FROM project_roots WHERE project_id = ? AND root_path = ?")
127
+ .get(projectId, rootPath) as { remote_url: string | null } | undefined;
128
+ if (!current) throw new ValidationError(`project root not found: ${rootPath}`);
129
+ if (opts.onlyIfNull && current.remote_url) return rootsForProject(projectId);
130
+ getDb().query("UPDATE project_roots SET remote_url = ? WHERE project_id = ? AND root_path = ?").run(value, projectId, rootPath);
131
+ return rootsForProject(projectId);
132
+ }
133
+
106
134
  interface EnsureProjectInput {
107
135
  slug: string;
108
136
  title?: string;
@@ -216,16 +244,20 @@ export function setProjectIssueRepo(id: string, issueRepo: string | null, opts:
216
244
  }
217
245
 
218
246
  /** Register a root path for a project (idempotent — UNIQUE(project_id, root_path)). */
219
- export function addProjectRoot(id: string, rootPath: string): ProjectRoot[] {
247
+ export function addProjectRoot(id: string, rootPath: string, opts: { remoteUrl?: string | null; ref?: string | null } = {}): ProjectRoot[] {
220
248
  const project = getProject(id);
221
249
  if (!project) throw new ValidationError(`project ${id} not found`);
222
250
  const path = rootPath.trim();
223
251
  if (!path) throw new ValidationError("rootPath required");
252
+ const remoteUrl = opts.remoteUrl?.trim() || null;
253
+ const ref = opts.ref?.trim() || null;
224
254
  getDb().query(`
225
- INSERT INTO project_roots (project_id, root_path, created_at)
226
- VALUES (?, ?, ?)
227
- ON CONFLICT(project_id, root_path) DO NOTHING
228
- `).run(id, path, Date.now());
255
+ INSERT INTO project_roots (project_id, root_path, remote_url, ref, created_at)
256
+ VALUES (?, ?, ?, ?, ?)
257
+ ON CONFLICT(project_id, root_path) DO UPDATE SET
258
+ remote_url = coalesce(excluded.remote_url, project_roots.remote_url),
259
+ ref = coalesce(excluded.ref, project_roots.ref)
260
+ `).run(id, path, remoteUrl, ref, Date.now());
229
261
  return rootsForProject(id);
230
262
  }
231
263
 
package/src/db/schema.ts CHANGED
@@ -268,6 +268,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
268
268
  CREATE TABLE IF NOT EXISTS project_roots (
269
269
  project_id TEXT NOT NULL,
270
270
  root_path TEXT NOT NULL,
271
+ remote_url TEXT,
272
+ ref TEXT,
271
273
  created_at INTEGER NOT NULL,
272
274
  UNIQUE(project_id, root_path)
273
275
  );
package/src/land-gates.ts CHANGED
@@ -1,80 +1,8 @@
1
- import { isAbsolute, relative, resolve } from "node:path";
2
- import { readFileSync } from "node:fs";
3
- import { isRecord } from "agent-relay-sdk";
4
- import type { LandGate } from "agent-relay-sdk";
5
- import { ValidationError } from "./db/connection.ts";
6
- import { cleanString } from "./validation.ts";
7
- import { parseJson, isPathWithinBase } from "./utils.ts";
8
-
9
- const LAND_GATES_FILE = ".agent-relay/land-gates.json";
10
-
11
- export function cleanLandGate(value: unknown, index: number, worktreePath?: string): LandGate {
12
- if (!isRecord(value)) throw new ValidationError(`gates[${index}] must be an object`);
13
- const name = cleanString(value.name, `gates[${index}].name`, { required: true, max: 120 });
14
- if (!name) throw new ValidationError(`gates[${index}].name required`);
15
- const command = cleanString(value.command, `gates[${index}].command`, { required: true, max: 4096 });
16
- if (!command) throw new ValidationError(`gates[${index}].command required`);
17
-
18
- let cwd: string | undefined;
19
- if (value.cwd !== undefined && value.cwd !== null) {
20
- const rawCwd = cleanString(value.cwd, `gates[${index}].cwd`, { max: 1000 });
21
- if (rawCwd) {
22
- if (worktreePath) {
23
- const resolved = resolve(worktreePath, rawCwd);
24
- if (!isPathWithinBase(resolved, worktreePath)) {
25
- throw new ValidationError(`gates[${index}].cwd must be within the worktree`);
26
- }
27
- cwd = relative(worktreePath, resolved) || ".";
28
- } else {
29
- if (isAbsolute(rawCwd)) throw new ValidationError(`gates[${index}].cwd must be a relative path when no worktreePath is provided`);
30
- cwd = rawCwd;
31
- }
32
- }
33
- }
34
-
35
- if (value.timeoutMs !== undefined && value.timeoutMs !== null) {
36
- if (typeof value.timeoutMs !== "number" || !Number.isSafeInteger(value.timeoutMs) || value.timeoutMs <= 0) {
37
- throw new ValidationError(`gates[${index}].timeoutMs must be a positive integer`);
38
- }
39
- }
40
- if (value.optional !== undefined && value.optional !== null) {
41
- if (typeof value.optional !== "boolean") {
42
- throw new ValidationError(`gates[${index}].optional must be a boolean`);
43
- }
44
- }
45
-
46
- return {
47
- name,
48
- command,
49
- ...(cwd !== undefined ? { cwd } : {}),
50
- ...(typeof value.timeoutMs === "number" ? { timeoutMs: value.timeoutMs as number } : {}),
51
- ...(typeof value.optional === "boolean" ? { optional: value.optional as boolean } : {}),
52
- };
53
- }
54
-
55
- /** Parse + validate a raw `.agent-relay/land-gates.json` object. */
56
- export function cleanLandGatesConfig(value: unknown, worktreePath?: string): LandGate[] {
57
- if (!isRecord(value)) throw new ValidationError("land-gates config must be an object");
58
- const { gates } = value;
59
- if (!Array.isArray(gates)) throw new ValidationError("gates must be an array");
60
- if (gates.length > 50) throw new ValidationError("gates: at most 50 gates allowed");
61
- return gates.map((g, i) => cleanLandGate(g, i, worktreePath));
62
- }
63
-
64
- /**
65
- * Read + validate `.agent-relay/land-gates.json` from a worktree path.
66
- * Missing file → empty list (opt-in, zero regression).
67
- */
68
- export function loadRepoLandGates(worktreePath: string): LandGate[] {
69
- const filePath = resolve(worktreePath, LAND_GATES_FILE);
70
- let raw: string;
71
- try {
72
- raw = readFileSync(filePath, "utf8");
73
- } catch (err: unknown) {
74
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
75
- throw err;
76
- }
77
- const parsed = parseJson<unknown>(raw, null);
78
- if (parsed === null) throw new ValidationError(`${LAND_GATES_FILE}: invalid JSON`);
79
- return cleanLandGatesConfig(parsed, worktreePath);
80
- }
1
+ // The land-gates config loader (#892) now lives in the SDK so the orchestrator
2
+ // package which advances `refs/heads/main` and therefore runs the gates — can
3
+ // import the SAME implementation across the package boundary (it can only depend
4
+ // on agent-relay-sdk, not this server package). Re-exported here so #892's
5
+ // importers and co-located tests stay byte-unchanged. The canonical home is
6
+ // `agent-relay-sdk/land-gates` (a non-barrel subpath: it reads from disk, so it
7
+ // stays out of browser bundles).
8
+ export { cleanLandGate, cleanLandGatesConfig, loadRepoLandGates } from "agent-relay-sdk/land-gates";
@@ -59,6 +59,8 @@ export const PROJECT_TOOLS = [
59
59
  properties: {
60
60
  slug: { type: "string" },
61
61
  rootPath: { type: "string" },
62
+ remoteUrl: { type: "string" },
63
+ ref: { type: "string" },
62
64
  title: { type: "string" },
63
65
  description: { type: "string" },
64
66
  tenantId: { type: "string" },
@@ -271,6 +273,8 @@ export function relayProjectTool(auth: McpAuthLike, name: string, args: Record<s
271
273
  const { project, roots } = ensureProjectRoot({
272
274
  slug,
273
275
  rootPath,
276
+ remoteUrl: cleanString(args.remoteUrl, "remoteUrl", { max: 4000 }),
277
+ ref: cleanString(args.ref, "ref", { max: 240 }),
274
278
  title: cleanString(args.title, "title", { max: 240 }),
275
279
  description: cleanString(args.description, "description", { max: 16_000 }),
276
280
  tenantId: cleanString(args.tenantId, "tenantId", { max: 200 }),
@@ -40,6 +40,8 @@ export type NotificationType =
40
40
  | "workspace.steward_reassigned"
41
41
  | "workspace.stranded_escalation"
42
42
  | "workspace.base_checkout_strand"
43
+ | "workspace.land_gate_failed"
44
+ | "workspace.land_gate_warning"
43
45
  | "workspace.auto_abandoned"
44
46
  | "workspace.review_request"
45
47
  | "workspace.review_changes_requested"
@@ -136,6 +138,13 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
136
138
  "workspace.steward_reassigned": null,
137
139
  "workspace.stranded_escalation": null,
138
140
  "workspace.base_checkout_strand": null,
141
+ // #902 — a required land gate blocked a land; the worker is parked idle after `ready` and
142
+ // must learn why its land bounced, so this is durable (about the recipient's own work).
143
+ "workspace.land_gate_failed": null,
144
+ // Optional-gate warning on an already-successful land — only useful while fresh, so it
145
+ // expires like other bystander/informational notices (= INFORMATIONAL_TTL_MS, inlined to
146
+ // avoid referencing that const before its declaration below).
147
+ "workspace.land_gate_warning": 30 * MINUTES,
139
148
  "workspace.auto_abandoned": null,
140
149
  "workspace.review_request": null,
141
150
  "workspace.review_changes_requested": null,