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.
- package/docs/openapi.json +1 -1
- package/package.json +3 -3
- package/public/assets/types-DSwhOFcJ.js.map +1 -1
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/artifact-text.ts +36 -0
- package/src/auto-spawn-dispatch.ts +136 -0
- package/src/branch-landed.ts +127 -1
- package/src/db/connection.ts +5 -1
- package/src/db/migrations.ts +22 -2
- package/src/db/projects.ts +38 -6
- package/src/db/schema.ts +2 -0
- package/src/land-gates.ts +8 -80
- package/src/mcp-project-tools.ts +4 -0
- package/src/notification-types.ts +9 -0
- package/src/routes/agents-spawn.ts +35 -6
- package/src/routes/commands.ts +91 -7
- package/src/services/git-remote.ts +22 -11
- package/src/services/project.ts +7 -2
- package/src/services/spawn-agent.ts +31 -4
- package/src/services/task-context-hydration.ts +21 -7
- package/src/spawn-command.ts +11 -0
- package/src/steward.ts +38 -6
- package/src/task-dispatcher.ts +94 -7
- package/src/utils.ts +7 -38
- package/src/validation.ts +6 -25
|
@@ -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
|
+
}
|
package/src/branch-landed.ts
CHANGED
|
@@ -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
|
package/src/db/connection.ts
CHANGED
|
@@ -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
|
-
|
|
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 {}
|
package/src/db/migrations.ts
CHANGED
|
@@ -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[];
|
package/src/db/projects.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
228
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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";
|
package/src/mcp-project-tools.ts
CHANGED
|
@@ -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,
|