agent-relay-server 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +4 -1
- package/package.json +2 -2
- package/public/index.html +698 -140
- package/runner/src/adapter.ts +1 -1
- package/scripts/install-bin-shim.cjs +16 -3
- package/src/agent-ref.ts +217 -0
- package/src/automations.ts +4 -1
- package/src/cli.ts +68 -8
- package/src/config-store.ts +35 -1
- package/src/context-router.ts +7 -7
- package/src/db.ts +111 -29
- package/src/maintenance.ts +9 -4
- package/src/managed-policy.ts +4 -1
- package/src/mcp.ts +452 -69
- package/src/routes.ts +94 -170
- package/src/runtime-tokens.ts +26 -1
- package/src/security.ts +3 -1
- package/src/workspace-actions.ts +336 -0
- package/src/workspace-phase.ts +181 -0
package/src/db.ts
CHANGED
|
@@ -74,6 +74,7 @@ import type {
|
|
|
74
74
|
WorkspaceStatus,
|
|
75
75
|
} from "./types";
|
|
76
76
|
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "./config";
|
|
77
|
+
import { matchAgents } from "./agent-ref";
|
|
77
78
|
|
|
78
79
|
let db: Database;
|
|
79
80
|
const CONTEXT_SNAPSHOT_DEBOUNCE_MS = 60_000;
|
|
@@ -210,6 +211,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
210
211
|
provider_capabilities TEXT,
|
|
211
212
|
context_state TEXT,
|
|
212
213
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
214
|
+
spawned_by TEXT,
|
|
213
215
|
last_seen INTEGER NOT NULL,
|
|
214
216
|
created_at INTEGER NOT NULL
|
|
215
217
|
);
|
|
@@ -1047,6 +1049,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
1047
1049
|
if (!agentColNames.includes("context_state")) {
|
|
1048
1050
|
db.run("ALTER TABLE agents ADD COLUMN context_state TEXT");
|
|
1049
1051
|
}
|
|
1052
|
+
if (!agentColNames.includes("spawned_by")) {
|
|
1053
|
+
db.run("ALTER TABLE agents ADD COLUMN spawned_by TEXT");
|
|
1054
|
+
}
|
|
1050
1055
|
db.run(`
|
|
1051
1056
|
CREATE TABLE IF NOT EXISTS context_snapshots (
|
|
1052
1057
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -1245,6 +1250,7 @@ function rowToAgent(row: any): AgentCard {
|
|
|
1245
1250
|
providerCapabilities: parseJson<ProviderCapabilities | undefined>(row.provider_capabilities, undefined),
|
|
1246
1251
|
context: parseJson<ContextState | undefined>(row.context_state, undefined),
|
|
1247
1252
|
meta: parseJson(row.meta, {}),
|
|
1253
|
+
spawnedBy: row.spawned_by ?? undefined,
|
|
1248
1254
|
lastSeen: row.last_seen,
|
|
1249
1255
|
createdAt: row.created_at,
|
|
1250
1256
|
};
|
|
@@ -1747,8 +1753,8 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
1747
1753
|
const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
|
|
1748
1754
|
const instanceProvided = Boolean(input.instanceId);
|
|
1749
1755
|
const stmt = db.query(`
|
|
1750
|
-
INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, last_seen, created_at)
|
|
1751
|
-
VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $now, $now)
|
|
1756
|
+
INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, spawned_by, last_seen, created_at)
|
|
1757
|
+
VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $spawnedBy, $now, $now)
|
|
1752
1758
|
ON CONFLICT(id) DO UPDATE SET
|
|
1753
1759
|
name = $name,
|
|
1754
1760
|
kind = $kind,
|
|
@@ -1767,6 +1773,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
1767
1773
|
provider_capabilities = coalesce($providerCapabilities, agents.provider_capabilities),
|
|
1768
1774
|
context_state = coalesce($contextState, agents.context_state),
|
|
1769
1775
|
meta = $meta,
|
|
1776
|
+
spawned_by = coalesce($spawnedBy, agents.spawned_by),
|
|
1770
1777
|
last_seen = $now
|
|
1771
1778
|
`);
|
|
1772
1779
|
|
|
@@ -1789,6 +1796,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
1789
1796
|
$providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
|
|
1790
1797
|
$contextState: input.context ? JSON.stringify(input.context) : null,
|
|
1791
1798
|
$meta: JSON.stringify(input.meta ?? {}),
|
|
1799
|
+
$spawnedBy: input.spawnedBy ?? null,
|
|
1792
1800
|
$now: now,
|
|
1793
1801
|
});
|
|
1794
1802
|
if (input.context) recordContextSnapshot(input.id, input.context, now);
|
|
@@ -1858,6 +1866,106 @@ export function listAgents(filter?: {
|
|
|
1858
1866
|
return (db.query(sql).all(...params) as any[]).map(rowToAgent);
|
|
1859
1867
|
}
|
|
1860
1868
|
|
|
1869
|
+
export interface AgentSearchFilter {
|
|
1870
|
+
/** Substring match on label OR name (case-insensitive). */
|
|
1871
|
+
label?: string;
|
|
1872
|
+
/** One or more capabilities (OR within the field), via json_each(capabilities). */
|
|
1873
|
+
capability?: string | string[];
|
|
1874
|
+
/** One or more tags (OR within the field), via json_each(tags). */
|
|
1875
|
+
tag?: string | string[];
|
|
1876
|
+
/** Real model provider — providerCapabilities.model.provider (e.g. "claude", "codex"). */
|
|
1877
|
+
provider?: string | string[];
|
|
1878
|
+
machine?: string | string[];
|
|
1879
|
+
/** provider/orchestrator/channel/etc. */
|
|
1880
|
+
kind?: string | string[];
|
|
1881
|
+
/** Specific status(es), the "running" shorthand (live + ready + fresh), or "all". */
|
|
1882
|
+
status?: string | string[];
|
|
1883
|
+
/** Exact parent agent id — backs the `spawnedBy:` / `spawnedBy:me` fleet filter (#221). */
|
|
1884
|
+
spawnedBy?: string;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
export interface AgentSearchSort {
|
|
1888
|
+
sortBy?: "lastActive" | "created" | "label";
|
|
1889
|
+
order?: "asc" | "desc";
|
|
1890
|
+
limit?: number;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const AGENT_SEARCH_MAX_LIMIT = 200;
|
|
1894
|
+
|
|
1895
|
+
function searchValues(value: string | string[] | undefined): string[] {
|
|
1896
|
+
if (value == null) return [];
|
|
1897
|
+
return (Array.isArray(value) ? value : [value]).filter((v): v is string => typeof v === "string" && v.length > 0);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// Unified fleet search: all filters optional and AND-combined; array values OR within a
|
|
1901
|
+
// field. One parameterized query (no JS post-filter, no injection). Backs relay_find_agents
|
|
1902
|
+
// (#219) and the running-only relay_agent_status default (#218). "running" mirrors the
|
|
1903
|
+
// canonical liveness predicate (see db.ts delivery/steward checks + isAgentOnline).
|
|
1904
|
+
export function searchAgents(filter: AgentSearchFilter = {}, sort: AgentSearchSort = {}): AgentCard[] {
|
|
1905
|
+
const clauses: string[] = ["1=1"];
|
|
1906
|
+
const params: any[] = [];
|
|
1907
|
+
const inList = (column: string, values: string[]) => {
|
|
1908
|
+
clauses.push(`${column} IN (${values.map(() => "?").join(",")})`);
|
|
1909
|
+
params.push(...values);
|
|
1910
|
+
};
|
|
1911
|
+
|
|
1912
|
+
if (filter.label) {
|
|
1913
|
+
clauses.push("(label LIKE ? OR name LIKE ?)");
|
|
1914
|
+
params.push(`%${filter.label}%`, `%${filter.label}%`);
|
|
1915
|
+
}
|
|
1916
|
+
const caps = searchValues(filter.capability);
|
|
1917
|
+
if (caps.length) {
|
|
1918
|
+
clauses.push(`EXISTS (SELECT 1 FROM json_each(capabilities) WHERE value IN (${caps.map(() => "?").join(",")}))`);
|
|
1919
|
+
params.push(...caps);
|
|
1920
|
+
}
|
|
1921
|
+
const tags = searchValues(filter.tag);
|
|
1922
|
+
if (tags.length) {
|
|
1923
|
+
clauses.push(`EXISTS (SELECT 1 FROM json_each(tags) WHERE value IN (${tags.map(() => "?").join(",")}))`);
|
|
1924
|
+
params.push(...tags);
|
|
1925
|
+
}
|
|
1926
|
+
const providers = searchValues(filter.provider);
|
|
1927
|
+
if (providers.length) {
|
|
1928
|
+
clauses.push(`json_extract(provider_capabilities, '$.model.provider') IN (${providers.map(() => "?").join(",")})`);
|
|
1929
|
+
params.push(...providers);
|
|
1930
|
+
}
|
|
1931
|
+
const machines = searchValues(filter.machine);
|
|
1932
|
+
if (machines.length) inList("machine", machines);
|
|
1933
|
+
const kinds = searchValues(filter.kind);
|
|
1934
|
+
if (kinds.length) inList("kind", kinds);
|
|
1935
|
+
if (filter.spawnedBy) {
|
|
1936
|
+
clauses.push("spawned_by = ?");
|
|
1937
|
+
params.push(filter.spawnedBy);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const statuses = searchValues(filter.status);
|
|
1941
|
+
if (!statuses.includes("all")) {
|
|
1942
|
+
if (statuses.includes("running")) {
|
|
1943
|
+
clauses.push("status NOT IN ('offline', 'stale') AND ready = 1 AND last_seen > ?");
|
|
1944
|
+
params.push(Date.now() - STALE_TTL_MS);
|
|
1945
|
+
}
|
|
1946
|
+
const explicit = statuses.filter((s) => s !== "running" && s !== "all");
|
|
1947
|
+
if (explicit.length) inList("status", explicit);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const sortColumn = sort.sortBy === "created" ? "created_at" : sort.sortBy === "label" ? "label COLLATE NOCASE" : "last_seen";
|
|
1951
|
+
const order = sort.order === "asc" ? "ASC" : "DESC";
|
|
1952
|
+
const limit = Math.max(1, Math.min(sort.limit ?? 50, AGENT_SEARCH_MAX_LIMIT));
|
|
1953
|
+
params.push(limit);
|
|
1954
|
+
|
|
1955
|
+
const sql = `SELECT * FROM agents WHERE ${clauses.join(" AND ")} ORDER BY ${sortColumn} ${order} LIMIT ?`;
|
|
1956
|
+
return (db.query(sql).all(...params) as any[]).map(rowToAgent);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// Count of a parent's currently-LIVE children — the spawn quota numerator (#221). "Live"
|
|
1960
|
+
// mirrors the running predicate (not offline/stale, ready, fresh); a child that exits frees
|
|
1961
|
+
// a slot. Uncapped COUNT (unlike searchAgents' LIMIT) so the quota is exact at any fleet size.
|
|
1962
|
+
export function countLiveSpawnedAgents(parentId: string, now: number = Date.now()): number {
|
|
1963
|
+
const row = db.query(
|
|
1964
|
+
"SELECT count(*) AS n FROM agents WHERE spawned_by = ? AND status NOT IN ('offline', 'stale') AND ready = 1 AND last_seen > ?",
|
|
1965
|
+
).get(parentId, now - STALE_TTL_MS) as { n: number };
|
|
1966
|
+
return row.n;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1861
1969
|
export function setStatus(id: string, status: AgentCard["status"], guard?: AgentSessionGuard): boolean {
|
|
1862
1970
|
if (!validateAgentSession(id, guard).ok) return false;
|
|
1863
1971
|
const now = Date.now();
|
|
@@ -2350,17 +2458,6 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
2350
2458
|
})();
|
|
2351
2459
|
}
|
|
2352
2460
|
|
|
2353
|
-
export function findAgentsByCapability(capability: string, onlineOnly = true): AgentCard[] {
|
|
2354
|
-
let sql = `SELECT * FROM agents WHERE EXISTS (SELECT 1 FROM json_each(capabilities) WHERE value = ?)`;
|
|
2355
|
-
const params: any[] = [capability];
|
|
2356
|
-
if (onlineOnly) {
|
|
2357
|
-
sql += ` AND status != 'offline' AND last_seen > ?`;
|
|
2358
|
-
params.push(Date.now() - STALE_TTL_MS);
|
|
2359
|
-
}
|
|
2360
|
-
sql += " ORDER BY last_seen DESC";
|
|
2361
|
-
return (db.query(sql).all(...params) as any[]).map(rowToAgent);
|
|
2362
|
-
}
|
|
2363
|
-
|
|
2364
2461
|
export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
2365
2462
|
if (id === "user" || id === "system") {
|
|
2366
2463
|
return { ok: false, error: "built-in agent cannot be deleted" };
|
|
@@ -3023,21 +3120,6 @@ function pairPeer(pair: PairSession, agentId: string): string {
|
|
|
3023
3120
|
return pair.requesterId === agentId ? pair.targetId : pair.requesterId;
|
|
3024
3121
|
}
|
|
3025
3122
|
|
|
3026
|
-
function agentMatchesPairTarget(agent: AgentCard, target: string): boolean {
|
|
3027
|
-
if (target.startsWith("id:")) return agent.id === target.slice(3);
|
|
3028
|
-
if (target.startsWith("label:")) return agent.label === target.slice(6);
|
|
3029
|
-
if (target.startsWith("tag:")) return agent.tags.includes(target.slice(4));
|
|
3030
|
-
if (target.startsWith("cap:")) return agent.capabilities.includes(target.slice(4));
|
|
3031
|
-
if (target.startsWith("rig:")) return agent.rig === target.slice(4);
|
|
3032
|
-
if (target.startsWith("machine:")) return agent.machine === target.slice(8);
|
|
3033
|
-
return agent.id === target ||
|
|
3034
|
-
agent.label === target ||
|
|
3035
|
-
agent.tags.includes(target) ||
|
|
3036
|
-
agent.capabilities.includes(target) ||
|
|
3037
|
-
agent.rig === target ||
|
|
3038
|
-
agent.name === target;
|
|
3039
|
-
}
|
|
3040
|
-
|
|
3041
3123
|
function resolvePairTarget(target: string, requesterId: string): {
|
|
3042
3124
|
ok: true;
|
|
3043
3125
|
agent: AgentCard;
|
|
@@ -3048,7 +3130,7 @@ function resolvePairTarget(target: string, requesterId: string): {
|
|
|
3048
3130
|
matches?: AgentCard[];
|
|
3049
3131
|
busy?: Array<{ agent: AgentCard; pair: PairSession }>;
|
|
3050
3132
|
} {
|
|
3051
|
-
const matches = listAgents()
|
|
3133
|
+
const matches = matchAgents(target, listAgents(), { excludeId: requesterId });
|
|
3052
3134
|
if (matches.length === 0) return { ok: false, code: "not_found", error: `no agent matches ${target}` };
|
|
3053
3135
|
|
|
3054
3136
|
const live = matches.filter((agent) => agent.status !== "offline" && agent.ready);
|
package/src/maintenance.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
35
35
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
36
36
|
import { workspaceActiveClaim } from "./workspace-claim";
|
|
37
|
+
import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
37
38
|
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
38
39
|
import { getStewardConfig } from "./config-store";
|
|
39
40
|
import { ensureRepoSteward } from "./steward";
|
|
@@ -86,7 +87,6 @@ const STRANDABLE_STATUSES = new Set<WorkspaceStatus>(["review_requested", "confl
|
|
|
86
87
|
// Live statuses worth scanning. Terminal (cleaned/merged/abandoned) and
|
|
87
88
|
// in-flight (cleanup_requested) states are skipped.
|
|
88
89
|
const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
|
|
89
|
-
const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
|
|
90
90
|
// In-flight merge statuses that should reconcile to `merged` once the host
|
|
91
91
|
// reports the branch's work has landed in base (squash/cherry-pick, or a merged
|
|
92
92
|
// PR). Excludes active/ready: an agent still working may have landed an early
|
|
@@ -695,7 +695,10 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
695
695
|
// base, and mergeRebaseFf rebases then aborts-to-conflict on a real replay
|
|
696
696
|
// conflict, so a too-optimistic prediction degrades safely to review_requested.
|
|
697
697
|
// Only a predicted conflict (true) or unknown state (undefined) goes to the steward.
|
|
698
|
-
|
|
698
|
+
// A no-op (#230) — nothing to land (ahead=0, clean) — is also landable: the host
|
|
699
|
+
// resolves it to a terminal `merged` status, draining the queue instead of waking
|
|
700
|
+
// the steward every sweep for a workspace with no work.
|
|
701
|
+
const canLand = p.noop === true || (p.conflict === false && ahead > 0);
|
|
699
702
|
if (!canLand) {
|
|
700
703
|
leftForSteward.push(ws.id);
|
|
701
704
|
if (stewardEnabled) {
|
|
@@ -716,8 +719,10 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
716
719
|
createActivityEvent({
|
|
717
720
|
clientId: `workspace-auto-merge-${ws.id}-${Date.now()}`,
|
|
718
721
|
kind: "state",
|
|
719
|
-
title: behind > 0 ? "Workspace auto-merging (rebase)" : "Workspace auto-merging (clean fast-forward)",
|
|
720
|
-
body:
|
|
722
|
+
title: p.noop ? "Workspace auto-resolving (no work to merge)" : behind > 0 ? "Workspace auto-merging (rebase)" : "Workspace auto-merging (clean fast-forward)",
|
|
723
|
+
body: p.noop
|
|
724
|
+
? `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (nothing to land — resolving to merged)`
|
|
725
|
+
: `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (${ahead} ahead${behind > 0 ? `, ${behind} behind — rebasing` : ", clean"})`,
|
|
721
726
|
meta: ws.branch ?? ws.id,
|
|
722
727
|
icon: "ti-git-merge",
|
|
723
728
|
view: "orchestrators",
|
package/src/managed-policy.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAgentProfile } from "./config-store";
|
|
1
|
+
import { getAgentProfile, spawnGrantForProfile } from "./config-store";
|
|
2
2
|
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
3
3
|
import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
|
|
4
4
|
import type { SpawnPolicy, WorkspaceMode } from "./types";
|
|
@@ -29,6 +29,7 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
|
|
|
29
29
|
const providerArgs = managedPolicyProviderArgs(policy);
|
|
30
30
|
const prompt = managedPolicyLaunchPrompt(policy);
|
|
31
31
|
const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
|
|
32
|
+
const grant = spawnGrantForProfile(policy.profile);
|
|
32
33
|
return buildSpawnCommand({
|
|
33
34
|
provider: policy.provider,
|
|
34
35
|
cwd: policy.cwd,
|
|
@@ -55,6 +56,8 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
|
|
|
55
56
|
policyName: policy.name,
|
|
56
57
|
spawnRequestId: requestId,
|
|
57
58
|
createdBy: ctx.createdBy,
|
|
59
|
+
canSpawn: grant.canSpawn,
|
|
60
|
+
maxSpawnedAgents: grant.maxSpawnedAgents,
|
|
58
61
|
}),
|
|
59
62
|
requestedBy: ctx.createdBy,
|
|
60
63
|
requestedAt: ctx.requestedAt ?? Date.now(),
|