agent-relay-server 0.120.3 → 0.120.5
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/{MessageBubble-Cqi3XsZ7.js → MessageBubble-BVff6PSi.js} +2 -2
- package/public/assets/{MessageBubble-Cqi3XsZ7.js.map → MessageBubble-BVff6PSi.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-D6Um05oW.js → PlanGraphPanel-BjrVU5Tg.js} +2 -2
- package/public/assets/{PlanGraphPanel-D6Um05oW.js.map → PlanGraphPanel-BjrVU5Tg.js.map} +1 -1
- package/public/assets/{activity-B0LsQHEz.js → activity-Bk9LEUr4.js} +2 -2
- package/public/assets/{activity-B0LsQHEz.js.map → activity-Bk9LEUr4.js.map} +1 -1
- package/public/assets/{agent-profiles-O--pe5Ic.js → agent-profiles-DSVDoQ_N.js} +2 -2
- package/public/assets/{agent-profiles-O--pe5Ic.js.map → agent-profiles-DSVDoQ_N.js.map} +1 -1
- package/public/assets/{agents-Ii6XlVwR.js → agents-8rQuJqii.js} +2 -2
- package/public/assets/{agents-Ii6XlVwR.js.map → agents-8rQuJqii.js.map} +1 -1
- package/public/assets/{analytics-BCzdYWRT.js → analytics-DifGnk7q.js} +2 -2
- package/public/assets/{analytics-BCzdYWRT.js.map → analytics-DifGnk7q.js.map} +1 -1
- package/public/assets/{automation-Crr3Xzui.js → automation-CMxQAf3l.js} +2 -2
- package/public/assets/{automation-Crr3Xzui.js.map → automation-CMxQAf3l.js.map} +1 -1
- package/public/assets/{branch-state-badge-Bhw2rloy.js → branch-state-badge-CyC5Qrhc.js} +2 -2
- package/public/assets/{branch-state-badge-Bhw2rloy.js.map → branch-state-badge-CyC5Qrhc.js.map} +1 -1
- package/public/assets/{channels-CKs9rvsn.js → channels-BLkdt2XR.js} +2 -2
- package/public/assets/{channels-CKs9rvsn.js.map → channels-BLkdt2XR.js.map} +1 -1
- package/public/assets/{chat-BCwEUj5R.js → chat-jlXywb9G.js} +2 -2
- package/public/assets/{chat-BCwEUj5R.js.map → chat-jlXywb9G.js.map} +1 -1
- package/public/assets/{connectors-D_kQR_vw.js → connectors-C0n12Je8.js} +2 -2
- package/public/assets/{connectors-D_kQR_vw.js.map → connectors-C0n12Je8.js.map} +1 -1
- package/public/assets/{formatted-body-q_qzt1L-.js → formatted-body-CzehtPtx.js} +3 -3
- package/public/assets/{formatted-body-q_qzt1L-.js.map → formatted-body-CzehtPtx.js.map} +1 -1
- package/public/assets/{formatted-body-impl-CSRGuMcM.js → formatted-body-impl-D3Bnt0Fj.js} +2 -2
- package/public/assets/{formatted-body-impl-CSRGuMcM.js.map → formatted-body-impl-D3Bnt0Fj.js.map} +1 -1
- package/public/assets/{index-Ceiwd02X.js → index-4g3PbGVN.js} +6 -6
- package/public/assets/{index-Ceiwd02X.js.map → index-4g3PbGVN.js.map} +1 -1
- package/public/assets/{insights-DUhXqZTZ.js → insights-3H6KttBi.js} +2 -2
- package/public/assets/{insights-DUhXqZTZ.js.map → insights-3H6KttBi.js.map} +1 -1
- package/public/assets/{integrations-lbg5CjtE.js → integrations-v14ehskT.js} +2 -2
- package/public/assets/{integrations-lbg5CjtE.js.map → integrations-v14ehskT.js.map} +1 -1
- package/public/assets/{maintenance-CQgneSVf.js → maintenance-BANYcBcn.js} +2 -2
- package/public/assets/{maintenance-CQgneSVf.js.map → maintenance-BANYcBcn.js.map} +1 -1
- package/public/assets/{managed-agents-DsnYf6fm.js → managed-agents-DCFMWKqt.js} +2 -2
- package/public/assets/{managed-agents-DsnYf6fm.js.map → managed-agents-DCFMWKqt.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-C1ZtGJp6.js → markdown-preview-impl-CfaR-GRX.js} +2 -2
- package/public/assets/{markdown-preview-impl-C1ZtGJp6.js.map → markdown-preview-impl-CfaR-GRX.js.map} +1 -1
- package/public/assets/{memory-SSLUTOI2.js → memory-BmkUEyd5.js} +2 -2
- package/public/assets/{memory-SSLUTOI2.js.map → memory-BmkUEyd5.js.map} +1 -1
- package/public/assets/{messages-BY26OP4K.js → messages-BQ_fVnSA.js} +2 -2
- package/public/assets/{messages-BY26OP4K.js.map → messages-BQ_fVnSA.js.map} +1 -1
- package/public/assets/{orchestrators-OY7yPzVI.js → orchestrators-DC7y9vQD.js} +2 -2
- package/public/assets/{orchestrators-OY7yPzVI.js.map → orchestrators-DC7y9vQD.js.map} +1 -1
- package/public/assets/{overview-Dj0u1tPG.js → overview-6lzywFHq.js} +2 -2
- package/public/assets/{overview-Dj0u1tPG.js.map → overview-6lzywFHq.js.map} +1 -1
- package/public/assets/{pairs-CrUR50ou.js → pairs-CRrRIaHI.js} +2 -2
- package/public/assets/{pairs-CrUR50ou.js.map → pairs-CRrRIaHI.js.map} +1 -1
- package/public/assets/{projects-B_AtxHtH.js → projects-CZJW35C7.js} +2 -2
- package/public/assets/{projects-B_AtxHtH.js.map → projects-CZJW35C7.js.map} +1 -1
- package/public/assets/{prompt-settings-CPtYSban.js → prompt-settings-DvlnMq8H.js} +2 -2
- package/public/assets/{prompt-settings-CPtYSban.js.map → prompt-settings-DvlnMq8H.js.map} +1 -1
- package/public/assets/{registry-BgUALWn5.js → registry-3TWg4zbE.js} +2 -2
- package/public/assets/{registry-BgUALWn5.js.map → registry-3TWg4zbE.js.map} +1 -1
- package/public/assets/{security-5lHfGk7g.js → security-A2VYKHeI.js} +2 -2
- package/public/assets/{security-5lHfGk7g.js.map → security-A2VYKHeI.js.map} +1 -1
- package/public/assets/{select-Cgx4QSLj.js → select-_gK5Ojjc.js} +2 -2
- package/public/assets/{select-Cgx4QSLj.js.map → select-_gK5Ojjc.js.map} +1 -1
- package/public/assets/{settings-DFGgxyun.js → settings-BZ26ewcu.js} +2 -2
- package/public/assets/{settings-DFGgxyun.js.map → settings-BZ26ewcu.js.map} +1 -1
- package/public/assets/store-nXGxgz2Q.js +9 -0
- package/public/assets/store-nXGxgz2Q.js.map +1 -0
- package/public/assets/{tasks-BvH5rluu.js → tasks-DlokSl_P.js} +2 -2
- package/public/assets/{tasks-BvH5rluu.js.map → tasks-DlokSl_P.js.map} +1 -1
- package/public/assets/{teams-VZOIOdoE.js → teams-t7M1HMgS.js} +2 -2
- package/public/assets/{teams-VZOIOdoE.js.map → teams-t7M1HMgS.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DYY3Wmoe.js → terminal-viewer-impl-DxgrO-jH.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DYY3Wmoe.js.map → terminal-viewer-impl-DxgrO-jH.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-B6Xk7QNe.js → work-queue-V8LR-3Gi.js} +2 -2
- package/public/assets/{work-queue-B6Xk7QNe.js.map → work-queue-V8LR-3Gi.js.map} +1 -1
- package/public/assets/{workspaces-CPIzAHAe.js → workspaces-DI317fMk.js} +2 -2
- package/public/assets/{workspaces-CPIzAHAe.js.map → workspaces-DI317fMk.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/auto-spawn-dispatch.ts +2 -2
- package/src/db/index.ts +2 -0
- package/src/db/migrations.ts +2 -1
- package/src/db/provisioning-callmux.ts +180 -0
- package/src/db/provisioning-registry.ts +45 -374
- package/src/db/provisioning-resolve.ts +245 -0
- package/src/routes/workspaces.ts +58 -22
- package/src/task-dispatcher.ts +12 -3
- package/src/workspace-orphans.ts +5 -1
- package/public/assets/store-BM4zM89B.js +0 -9
- package/public/assets/store-BM4zM89B.js.map +0 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Profile provisioning resolution lives outside provisioning-registry.ts so the
|
|
2
|
+
// registry CRUD/vendoring module stays under the file-size ratchet.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { getManifest, type ProviderManifest } from "agent-relay-providers";
|
|
7
|
+
import { isRecord } from "agent-relay-sdk";
|
|
8
|
+
import type {
|
|
9
|
+
AgentProfile,
|
|
10
|
+
AgentProfileAssetRef,
|
|
11
|
+
AgentProfileProvider,
|
|
12
|
+
PluginAssetDefinition,
|
|
13
|
+
ProvisioningAssetKind,
|
|
14
|
+
ProvisioningMcpServer,
|
|
15
|
+
ProvisioningSkip,
|
|
16
|
+
ResolvedAssetDefinition,
|
|
17
|
+
ResolvedProvisioning,
|
|
18
|
+
SkillAssetDefinition,
|
|
19
|
+
SpawnProvider,
|
|
20
|
+
} from "agent-relay-sdk";
|
|
21
|
+
import { getDb } from "./connection.ts";
|
|
22
|
+
import { parseJson } from "../utils";
|
|
23
|
+
|
|
24
|
+
type ProviderBundledSkillSetDescriptor = NonNullable<NonNullable<ProviderManifest["provisioning"]>["bundledSkills"]>;
|
|
25
|
+
|
|
26
|
+
interface ProvisioningCapabilityResolutionRow {
|
|
27
|
+
id: string;
|
|
28
|
+
slug: string;
|
|
29
|
+
kind: ProvisioningAssetKind;
|
|
30
|
+
status: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ProvisioningAssetResolutionRow {
|
|
34
|
+
kind: ProvisioningAssetKind;
|
|
35
|
+
name: string;
|
|
36
|
+
provider: AgentProfileProvider;
|
|
37
|
+
definition: string;
|
|
38
|
+
enabled: number;
|
|
39
|
+
approval_status?: string | null;
|
|
40
|
+
validation_status?: string | null;
|
|
41
|
+
validation_errors?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface RegistryResolution {
|
|
45
|
+
definition: ResolvedAssetDefinition | null;
|
|
46
|
+
skip?: ProvisioningSkip;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function bundledSkillRefs(descriptor: ProviderBundledSkillSetDescriptor): string[] {
|
|
50
|
+
const skillsDir = resolve(import.meta.dir, "../../", descriptor.dir);
|
|
51
|
+
const entryFile = descriptor.entryFile ?? "SKILL.md";
|
|
52
|
+
if (!existsSync(skillsDir)) return [];
|
|
53
|
+
return readdirSync(skillsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(join(skillsDir, entry.name, entryFile)))
|
|
54
|
+
.map((entry) => descriptor.refPrefix + entry.name).sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function stringRecord(value: Record<string, unknown>): Record<string, string> {
|
|
58
|
+
const out: Record<string, string> = {};
|
|
59
|
+
for (const [k, v] of Object.entries(value)) {
|
|
60
|
+
if (typeof v === "string") out[k] = v;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function rowDefinition(row: ProvisioningAssetResolutionRow): ResolvedAssetDefinition {
|
|
66
|
+
return parseJson<ResolvedAssetDefinition>(row.definition, { kind: row.kind, name: row.name } as ResolvedAssetDefinition);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function skip(kind: ProvisioningAssetKind, ref: string, provider: SpawnProvider, reason: ProvisioningSkip["reason"], detail: string): ProvisioningSkip {
|
|
70
|
+
return { kind, ref, provider, reason, detail };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function variantSkipReason(row: ProvisioningAssetResolutionRow): Pick<ProvisioningSkip, "reason" | "detail"> | null {
|
|
74
|
+
if (row.enabled !== 1) return { reason: "disabled", detail: "Registry variant is disabled." };
|
|
75
|
+
if (row.approval_status !== "approved") return { reason: "unapproved", detail: `Registry variant approval_status is ${row.approval_status ?? "unset"}.` };
|
|
76
|
+
if (row.validation_status !== "valid") {
|
|
77
|
+
const errors = parseJson<unknown[]>(row.validation_errors ?? "[]", []);
|
|
78
|
+
const detail = errors.length ? `Registry variant validation_status is ${row.validation_status}; ${JSON.stringify(errors)}` : `Registry variant validation_status is ${row.validation_status ?? "unset"}.`;
|
|
79
|
+
return { reason: "invalid", detail };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Resolve a registry ref for a target provider: an approved/valid/enabled exact
|
|
85
|
+
// provider match wins, else provider-neutral "any". MCP is intentionally
|
|
86
|
+
// provider-neutral in v1, so only "any" MCP variants are candidates.
|
|
87
|
+
function resolveRegistryDefinition(kind: ProvisioningAssetKind, ref: string, provider: SpawnProvider): RegistryResolution {
|
|
88
|
+
const capability = getDb()
|
|
89
|
+
.query("SELECT id, slug, kind, status FROM provisioning_capabilities WHERE kind = ? AND slug = ?")
|
|
90
|
+
.get(kind, ref) as ProvisioningCapabilityResolutionRow | undefined;
|
|
91
|
+
if (!capability) {
|
|
92
|
+
return { definition: null, skip: skip(kind, ref, provider, "missing", "No provisioning capability exists for this ref.") };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rows = getDb()
|
|
96
|
+
.query("SELECT * FROM provisioning_assets WHERE capability_id = ? ORDER BY CASE provider WHEN ? THEN 0 WHEN 'any' THEN 1 ELSE 2 END, provider")
|
|
97
|
+
.all(capability.id, provider) as ProvisioningAssetResolutionRow[];
|
|
98
|
+
const providerCandidates = rows.filter((row) => kind === "mcp" ? row.provider === "any" : row.provider === provider || row.provider === "any");
|
|
99
|
+
if (!providerCandidates.length) {
|
|
100
|
+
const label = getManifest(provider)?.label ?? provider;
|
|
101
|
+
return { definition: null, skip: skip(kind, ref, provider, "provider_mismatch", `No ${label} variant exists for capability ${ref}.`) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const row of providerCandidates) {
|
|
105
|
+
if (!variantSkipReason(row)) return { definition: rowDefinition(row) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const blocked = variantSkipReason(providerCandidates[0]!);
|
|
109
|
+
return { definition: null, skip: skip(kind, ref, provider, blocked?.reason ?? "missing", blocked?.detail ?? "No resolvable registry variant exists.") };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A ref applies to a launch iff it's enabled and its provider scope includes the
|
|
113
|
+
// target ("any"/undefined → all providers).
|
|
114
|
+
function refAppliesToProvider(ref: AgentProfileAssetRef, provider: SpawnProvider): boolean {
|
|
115
|
+
return ref.enabled && (!ref.provider || ref.provider === "any" || ref.provider === provider);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hasAssetRef(refs: AgentProfileAssetRef[], source: AgentProfileAssetRef["source"], ref: string): boolean {
|
|
119
|
+
return refs.some((item) => item.source === source && item.ref === ref);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function relayBundledProvisioningRefs(profile: AgentProfile, provider: SpawnProvider): Pick<AgentProfile, "skills" | "plugins"> {
|
|
123
|
+
const skills = [...(profile.skills ?? [])];
|
|
124
|
+
const plugins = [...(profile.plugins ?? [])];
|
|
125
|
+
const provisioning = getManifest(provider)?.provisioning;
|
|
126
|
+
if (profile.relay.skills !== false && provisioning?.bundledSkills) {
|
|
127
|
+
for (const ref of bundledSkillRefs(provisioning.bundledSkills)) {
|
|
128
|
+
if (!hasAssetRef(skills, "relay", ref)) skills.push({ source: "relay", ref, enabled: true, provider });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (profile.relay.plugins !== false && provisioning?.bundledPlugin && !hasAssetRef(plugins, "relay", provisioning.bundledPlugin.ref)) {
|
|
132
|
+
plugins.push({ source: "relay", ref: provisioning.bundledPlugin.ref, enabled: true, provider });
|
|
133
|
+
}
|
|
134
|
+
return { skills, plugins };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function withRelayBundledProvisioningRefs(profile: AgentProfile, provider: SpawnProvider): AgentProfile {
|
|
138
|
+
const { skills, plugins } = relayBundledProvisioningRefs(profile, provider);
|
|
139
|
+
return { ...profile, skills, plugins };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function inlineFilesFromMeta(meta: Record<string, unknown> | undefined): { path: string; content: string }[] | undefined {
|
|
143
|
+
const raw = meta?.files;
|
|
144
|
+
if (!Array.isArray(raw)) return undefined;
|
|
145
|
+
const files = raw
|
|
146
|
+
.filter(isRecord)
|
|
147
|
+
.filter((f) => typeof f.path === "string" && typeof f.content === "string")
|
|
148
|
+
.map((f) => ({ path: f.path as string, content: f.content as string }));
|
|
149
|
+
return files.length ? files : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Resolve a skill/plugin asset ref into a concrete definition. relay → registry;
|
|
153
|
+
// inline → built from ref.meta (files or dir); repo → a cwd-relative dir token the
|
|
154
|
+
// runner resolves against the launch cwd; provider → skipped (provider built-in).
|
|
155
|
+
function resolveDirAssetRef(
|
|
156
|
+
kind: "skill" | "plugin",
|
|
157
|
+
ref: AgentProfileAssetRef,
|
|
158
|
+
provider: SpawnProvider,
|
|
159
|
+
): { definition: SkillAssetDefinition | PluginAssetDefinition | null; skip?: ProvisioningSkip } {
|
|
160
|
+
if (ref.source === "relay") {
|
|
161
|
+
const resolved = resolveRegistryDefinition(kind, ref.ref, provider);
|
|
162
|
+
const def = resolved.definition;
|
|
163
|
+
return { definition: def && def.kind === kind ? def : null, skip: resolved.skip };
|
|
164
|
+
}
|
|
165
|
+
if (ref.source === "inline") {
|
|
166
|
+
const files = inlineFilesFromMeta(ref.meta);
|
|
167
|
+
const dir = typeof ref.meta?.dir === "string" ? ref.meta.dir : undefined;
|
|
168
|
+
if (!files && !dir) return { definition: null, skip: skip(kind, ref.ref, provider, "invalid_inline", `Inline ${kind} ref requires files or dir metadata.`) };
|
|
169
|
+
return { definition: { kind, name: ref.ref, ...(files ? { files } : {}), ...(dir ? { dir } : {}) } };
|
|
170
|
+
}
|
|
171
|
+
if (ref.source === "repo") {
|
|
172
|
+
return { definition: { kind, name: ref.ref, dir: ref.ref } };
|
|
173
|
+
}
|
|
174
|
+
return { definition: null };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A profile.mcp.servers entry is either an inline server spec or a registry
|
|
178
|
+
// reference (`{ source: "relay", ref }`). Returns the concrete server spec.
|
|
179
|
+
function resolveMcpEntry(name: string, value: unknown, provider: SpawnProvider): { server: ProvisioningMcpServer | null; skip?: ProvisioningSkip } {
|
|
180
|
+
if (!isRecord(value)) return { server: null };
|
|
181
|
+
if (value.source === "relay" && typeof value.ref === "string") {
|
|
182
|
+
const resolved = resolveRegistryDefinition("mcp", value.ref, provider);
|
|
183
|
+
const def = resolved.definition;
|
|
184
|
+
return { server: def && def.kind === "mcp" ? def.server : null, skip: resolved.skip };
|
|
185
|
+
}
|
|
186
|
+
if (value.enabled === false) return { server: null };
|
|
187
|
+
// Inline spec: keep the recognized server fields only.
|
|
188
|
+
const server: ProvisioningMcpServer = {};
|
|
189
|
+
if (typeof value.type === "string") server.type = value.type as ProvisioningMcpServer["type"];
|
|
190
|
+
if (typeof value.command === "string") server.command = value.command;
|
|
191
|
+
if (Array.isArray(value.args)) server.args = value.args.filter((a): a is string => typeof a === "string");
|
|
192
|
+
if (isRecord(value.env)) server.env = stringRecord(value.env);
|
|
193
|
+
if (isRecord(value.configEnv)) server.configEnv = stringRecord(value.configEnv);
|
|
194
|
+
if (typeof value.url === "string") server.url = value.url;
|
|
195
|
+
if (isRecord(value.headers)) server.headers = stringRecord(value.headers);
|
|
196
|
+
return { server: server.command || server.url ? server : null, skip: server.command || server.url ? undefined : skip("mcp", name, provider, "invalid_inline", "Inline MCP server requires command or url.") };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Resolve a profile's declared skill/plugin/MCP refs against the registry into the
|
|
200
|
+
// concrete bundle the runner materializes. Relay-side (needs the DB); the result is
|
|
201
|
+
// attached to the in-flight profile (profile.provisioning) before it's serialized
|
|
202
|
+
// into the spawn env. Pure read — never mutates the registry.
|
|
203
|
+
export function resolveProfileProvisioning(profile: AgentProfile, provider: SpawnProvider): ResolvedProvisioning {
|
|
204
|
+
const skills: SkillAssetDefinition[] = [];
|
|
205
|
+
const skipped: ProvisioningSkip[] = [];
|
|
206
|
+
for (const ref of profile.skills ?? []) {
|
|
207
|
+
if (!refAppliesToProvider(ref, provider)) {
|
|
208
|
+
if (ref.enabled && ref.source === "relay") skipped.push(skip("skill", ref.ref, provider, "provider_mismatch", `Profile ref is scoped to provider ${ref.provider}.`));
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const resolved = resolveDirAssetRef("skill", ref, provider);
|
|
212
|
+
if (resolved.definition) skills.push(resolved.definition as SkillAssetDefinition);
|
|
213
|
+
else if (resolved.skip) skipped.push(resolved.skip);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const plugins: PluginAssetDefinition[] = [];
|
|
217
|
+
for (const ref of profile.plugins ?? []) {
|
|
218
|
+
if (!refAppliesToProvider(ref, provider)) {
|
|
219
|
+
if (ref.enabled && ref.source === "relay") skipped.push(skip("plugin", ref.ref, provider, "provider_mismatch", `Profile ref is scoped to provider ${ref.provider}.`));
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const resolved = resolveDirAssetRef("plugin", ref, provider);
|
|
223
|
+
if (resolved.definition) plugins.push(resolved.definition as PluginAssetDefinition);
|
|
224
|
+
else if (resolved.skip) skipped.push(resolved.skip);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const mcpServers: Record<string, ProvisioningMcpServer> = {};
|
|
228
|
+
for (const [name, value] of Object.entries(profile.mcp?.servers ?? {})) {
|
|
229
|
+
const resolved = resolveMcpEntry(name, value, provider);
|
|
230
|
+
if (resolved.server) mcpServers[name] = resolved.server;
|
|
231
|
+
else if (resolved.skip) skipped.push(resolved.skip);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { skills, plugins, mcpServers, ...(skipped.length ? { skipped } : {}) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Attach the resolved bundle to a spawn-bound profile. Returns a shallow copy so the
|
|
238
|
+
// cached config-store value is never mutated. undefined passes through (an unresolved
|
|
239
|
+
// profile name stays undefined so callers' not-found guards still fire); the runner
|
|
240
|
+
// treats an absent and an empty bundle identically.
|
|
241
|
+
export function withResolvedProvisioning(profile: AgentProfile | undefined, provider: SpawnProvider): AgentProfile | undefined {
|
|
242
|
+
if (!profile) return undefined;
|
|
243
|
+
const composed = withRelayBundledProvisioningRefs(profile, provider);
|
|
244
|
+
return { ...composed, provisioning: resolveProfileProvisioning(composed, provider) };
|
|
245
|
+
}
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -104,12 +104,27 @@ function latestWorkspaceByBranch(workspaces: WorkspaceRecord[]): Map<string, Wor
|
|
|
104
104
|
return byBranch;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
function
|
|
108
|
-
|
|
107
|
+
function owningOrchestratorsForRepo(repoRoot: string, orchestratorId?: string) {
|
|
108
|
+
const candidates = listOrchestrators().filter((candidate) => candidate.status === "online" && candidate.apiUrl);
|
|
109
|
+
if (orchestratorId) {
|
|
110
|
+
return candidates.filter((candidate) => candidate.id === orchestratorId);
|
|
111
|
+
}
|
|
112
|
+
const ownerIds = new Set(
|
|
113
|
+
listWorkspaces()
|
|
114
|
+
.filter((workspace) => workspace.orchestratorId && resolve(workspace.repoRoot) === resolve(repoRoot))
|
|
115
|
+
.map((workspace) => workspace.orchestratorId!),
|
|
116
|
+
);
|
|
117
|
+
const owners = candidates.filter((candidate) => ownerIds.has(candidate.id));
|
|
118
|
+
if (owners.length > 0) return owners;
|
|
119
|
+
const legacy = candidates.find((candidate) => isPathWithinBase(repoRoot, candidate.baseDir));
|
|
120
|
+
return legacy ? [legacy] : [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function owningOrchestratorForRepo(repoRoot: string, orchestratorId?: string) {
|
|
124
|
+
return owningOrchestratorsForRepo(repoRoot, orchestratorId)[0];
|
|
109
125
|
}
|
|
110
126
|
|
|
111
|
-
async function
|
|
112
|
-
const orch = owningOrchestratorForRepo(repoRoot);
|
|
127
|
+
async function fetchRecoveryBranchesFromOrchestrator(repoRoot: string, orch: NonNullable<ReturnType<typeof owningOrchestratorForRepo>>, opts: { baseRef?: string; baseSha?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
|
|
113
128
|
if (!orch?.apiUrl) return { branches: [], unavailable: "owning orchestrator offline" };
|
|
114
129
|
const query = new URLSearchParams({ repoRoot });
|
|
115
130
|
if (opts.baseRef) query.set("baseRef", opts.baseRef);
|
|
@@ -122,16 +137,36 @@ async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?:
|
|
|
122
137
|
if (!res.ok) return { branches: [], unavailable: `host returned ${res.status}` };
|
|
123
138
|
const payload = await res.json() as unknown;
|
|
124
139
|
if (!isRecord(payload) || !Array.isArray(payload.branches)) return { branches: [], unavailable: "host returned malformed recovery branch list" };
|
|
125
|
-
return {
|
|
140
|
+
return {
|
|
141
|
+
branches: (payload.branches.filter(isRecord) as unknown as WorkspaceRecoveryBranch[]).map((branch) => ({
|
|
142
|
+
...branch,
|
|
143
|
+
orchestratorId: orch.id,
|
|
144
|
+
orchestratorAgentId: orch.agentId,
|
|
145
|
+
})),
|
|
146
|
+
};
|
|
126
147
|
} catch (e) {
|
|
127
148
|
return { branches: [], unavailable: `orchestrator unreachable: ${(e as Error).message}` };
|
|
128
149
|
}
|
|
129
150
|
}
|
|
130
151
|
|
|
152
|
+
async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?: string; baseSha?: string; orchestratorId?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
|
|
153
|
+
const hosts = owningOrchestratorsForRepo(repoRoot, opts.orchestratorId);
|
|
154
|
+
if (hosts.length === 0) return { branches: [], unavailable: "owning orchestrator offline" };
|
|
155
|
+
const branches: WorkspaceRecoveryBranch[] = [];
|
|
156
|
+
const unavailable: string[] = [];
|
|
157
|
+
for (const orch of hosts) {
|
|
158
|
+
const fetched = await fetchRecoveryBranchesFromOrchestrator(repoRoot, orch, opts);
|
|
159
|
+
branches.push(...fetched.branches);
|
|
160
|
+
if (fetched.unavailable) unavailable.push(`${orch.id}: ${fetched.unavailable}`);
|
|
161
|
+
}
|
|
162
|
+
return { branches, ...(branches.length === 0 && unavailable.length > 0 ? { unavailable: unavailable.join("; ") } : {}) };
|
|
163
|
+
}
|
|
164
|
+
|
|
131
165
|
export const getWorkspaceRecoveryBranches: Handler = async (req) => {
|
|
132
166
|
try {
|
|
133
167
|
const url = new URL(req.url);
|
|
134
168
|
const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
|
|
169
|
+
const orchestratorId = cleanString(url.searchParams.get("orchestratorId") ?? undefined, "orchestratorId", { max: 200 });
|
|
135
170
|
const baseRef = cleanString(url.searchParams.get("baseRef") ?? undefined, "baseRef", { max: 240 });
|
|
136
171
|
const baseSha = cleanString(url.searchParams.get("baseSha") ?? undefined, "baseSha", { max: 80 });
|
|
137
172
|
const includeActive = url.searchParams.get("includeActive") === "1";
|
|
@@ -142,7 +177,7 @@ export const getWorkspaceRecoveryBranches: Handler = async (req) => {
|
|
|
142
177
|
const unavailable: Record<string, string> = {};
|
|
143
178
|
|
|
144
179
|
for (const repo of repoRoots) {
|
|
145
|
-
const fetched = await fetchRecoveryBranchesForRepo(repo, { baseRef, baseSha });
|
|
180
|
+
const fetched = await fetchRecoveryBranchesForRepo(repo, { baseRef, baseSha, orchestratorId });
|
|
146
181
|
if (fetched.unavailable) {
|
|
147
182
|
unavailable[repo] = fetched.unavailable;
|
|
148
183
|
continue;
|
|
@@ -186,6 +221,7 @@ export const postWorkspaceRecoveryBranchDiscard: Handler = async (req) => {
|
|
|
186
221
|
if (!isRecord(parsed.body)) return error("body required");
|
|
187
222
|
const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
|
|
188
223
|
const branch = cleanString(parsed.body.branch, "branch", { max: 240 });
|
|
224
|
+
const requestedOrchestratorId = cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 });
|
|
189
225
|
const baseRef = cleanString(parsed.body.baseRef, "baseRef", { max: 240 });
|
|
190
226
|
const baseSha = cleanString(parsed.body.baseSha, "baseSha", { max: 80 });
|
|
191
227
|
const reason = cleanString(parsed.body.reason, "reason", { max: 1000 });
|
|
@@ -193,11 +229,11 @@ export const postWorkspaceRecoveryBranchDiscard: Handler = async (req) => {
|
|
|
193
229
|
if (!repoRoot || !branch) return error("repoRoot and branch required", 400);
|
|
194
230
|
if (!branch.startsWith("agent/")) return error("only agent/* branches can be discarded through recovery cleanup", 400);
|
|
195
231
|
if (force && !reason) return error("reason required for forced recovery branch discard", 400);
|
|
196
|
-
const
|
|
232
|
+
const fetched = await fetchRecoveryBranchesForRepo(repoRoot, { baseRef, baseSha, orchestratorId: requestedOrchestratorId });
|
|
233
|
+
const target = fetched.branches.find((item) => item.branch === branch);
|
|
234
|
+
const orch = target?.orchestratorId ? owningOrchestratorForRepo(repoRoot, target.orchestratorId) : owningOrchestratorForRepo(repoRoot);
|
|
197
235
|
if (!orch?.agentId) return error("no online orchestrator owns this repo", 409);
|
|
198
|
-
const fetched = await fetchRecoveryBranchesForRepo(repoRoot, { baseRef, baseSha });
|
|
199
236
|
if (fetched.unavailable) return error(fetched.unavailable, 409);
|
|
200
|
-
const target = fetched.branches.find((item) => item.branch === branch);
|
|
201
237
|
if (!target) return error("recovery branch not found", 404);
|
|
202
238
|
if (target.safeToDelete !== true && !force) {
|
|
203
239
|
return error(`branch holds work or cannot be proven safe (${target.preserveReason ?? `${target.unmergedAhead ?? target.ahead ?? "?"} unlanded commit(s)`}); land it first or pass force:true with reason to discard`, 409);
|
|
@@ -242,25 +278,25 @@ export const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
242
278
|
// Refuse to reclaim a path that still backs a live workspace row.
|
|
243
279
|
const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
|
|
244
280
|
if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
|
|
245
|
-
const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
246
|
-
if (!orch) return error("no online orchestrator owns this path", 409);
|
|
247
281
|
// Land-safety gate (#244): reclaim force-removes the worktree, so refuse when
|
|
248
282
|
// it holds un-landed work unless the caller explicitly opts into discarding
|
|
249
283
|
// it. Mirrors the scheduled reaper — never destroy work on uncertainty.
|
|
250
284
|
let baseRef: string | undefined;
|
|
251
285
|
let baseSha: string | undefined;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return error(`worktree holds un-landed work (${why}); recover it first, or pass {"force":true} to discard`, 409);
|
|
260
|
-
}
|
|
261
|
-
baseRef = target?.baseRef;
|
|
262
|
-
baseSha = target?.baseSha;
|
|
286
|
+
const { orphans } = await collectWorkspaceOrphans();
|
|
287
|
+
const target = orphans.find((o) => resolve(o.worktreePath) === resolve(worktreePath));
|
|
288
|
+
if (target && !force && target.safeToReap !== true) {
|
|
289
|
+
const why = target.safeToReap === undefined
|
|
290
|
+
? "land-state could not be determined"
|
|
291
|
+
: target.dirty ? "uncommitted changes" : `${target.unmergedAhead ?? target.ahead ?? "?"} un-landed commit(s)`;
|
|
292
|
+
return error(`worktree holds un-landed work (${why}); recover it first, or pass {"force":true} to discard`, 409);
|
|
263
293
|
}
|
|
294
|
+
baseRef = target?.baseRef;
|
|
295
|
+
baseSha = target?.baseSha;
|
|
296
|
+
const orch = target?.orchestratorId
|
|
297
|
+
? listOrchestrators().find((candidate) => candidate.id === target.orchestratorId && candidate.status === "online")
|
|
298
|
+
: listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
299
|
+
if (!orch) return error("no online orchestrator owns this path", 409);
|
|
264
300
|
const command = createCommand({
|
|
265
301
|
type: "workspace.cleanup",
|
|
266
302
|
source: "system",
|
package/src/task-dispatcher.ts
CHANGED
|
@@ -382,7 +382,8 @@ function rerouteForBand(
|
|
|
382
382
|
// no burn data yet. Prefer "prefer" band, then "normal"; never a blocked one.
|
|
383
383
|
//
|
|
384
384
|
// When mustResolveAvailable is set (#901 degrade-never-refuse), widens search in tiers when
|
|
385
|
-
// no prefer/normal provider is available: caution → avoid-large →
|
|
385
|
+
// no prefer/normal provider is available: caution → avoid-large → hard-avoid-but-not-blocked
|
|
386
|
+
// → least-utilized blockedNow.
|
|
386
387
|
// A `degraded` flag + `degradedReason` signals to the caller to emit a louder warning.
|
|
387
388
|
function healthiestAlternative(
|
|
388
389
|
avoid: SpawnProvider,
|
|
@@ -415,11 +416,19 @@ function healthiestAlternative(
|
|
|
415
416
|
.sort((a, b) => bandRank(a.band) - bandRank(b.band));
|
|
416
417
|
if (avoidLargeAlt[0]) return { ...avoidLargeAlt[0], degraded: true, degradedReason: "avoid-large band (tier 2 degraded)" };
|
|
417
418
|
|
|
418
|
-
// Tier 3 degradation:
|
|
419
|
+
// Tier 3 degradation: hard-avoid is still better than a provider that is actively blocked.
|
|
420
|
+
// This is the exact shape a release auto-heal can hit: Claude session limit is blockedNow,
|
|
421
|
+
// Codex is over pace (hard-avoid) but still runnable.
|
|
422
|
+
const hardAvoidAlt = all
|
|
423
|
+
.filter((c) => !c.blockedNow && c.band === "hard-avoid")
|
|
424
|
+
.sort((a, b) => bandRank(a.band) - bandRank(b.band));
|
|
425
|
+
if (hardAvoidAlt[0]) return { ...hardAvoidAlt[0], degraded: true, degradedReason: "hard-avoid band (tier 3 degraded)" };
|
|
426
|
+
|
|
427
|
+
// Tier 4 degradation: least-utilized blockedNow (last resort — emit the loudest warning).
|
|
419
428
|
// Sort by bandRank so the healthiest (lowest penalty) blocked provider is picked first,
|
|
420
429
|
// not whichever happens to be earliest in catalog order.
|
|
421
430
|
const blockedAlts = all.filter((c) => c.blockedNow).sort((a, b) => bandRank(a.band) - bandRank(b.band));
|
|
422
|
-
if (blockedAlts[0]) return { ...blockedAlts[0], degraded: true, degradedReason: "blockedNow provider (tier
|
|
431
|
+
if (blockedAlts[0]) return { ...blockedAlts[0], degraded: true, degradedReason: "blockedNow provider (tier 4 last-resort — ALL providers at capacity)" };
|
|
423
432
|
|
|
424
433
|
return undefined;
|
|
425
434
|
}
|
package/src/workspace-orphans.ts
CHANGED
|
@@ -252,6 +252,8 @@ export async function collectWorkspaceOrphans(): Promise<CollectOrphansResult> {
|
|
|
252
252
|
const orphan: WorkspaceOrphan = {
|
|
253
253
|
worktreePath: worktree.path,
|
|
254
254
|
repoRoot,
|
|
255
|
+
orchestratorId: orch.id,
|
|
256
|
+
orchestratorAgentId: orch.agentId,
|
|
255
257
|
branch: worktree.branch,
|
|
256
258
|
headSha: worktree.headSha,
|
|
257
259
|
baseRef: probe.branch,
|
|
@@ -366,7 +368,9 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
|
|
|
366
368
|
const liveCwds = liveAgentCwds();
|
|
367
369
|
|
|
368
370
|
for (const orphan of orphans) {
|
|
369
|
-
const orch =
|
|
371
|
+
const orch = orphan.orchestratorId
|
|
372
|
+
? orchestrators.find((candidate) => candidate.id === orphan.orchestratorId)
|
|
373
|
+
: orchestrators.find((candidate) => isPathWithinBase(orphan.repoRoot, candidate.baseDir));
|
|
370
374
|
if (!orch) continue;
|
|
371
375
|
|
|
372
376
|
if (orphan.safeToReap === true) {
|