agent-relay-server 0.65.1 → 0.66.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 +1 -1
- package/package.json +2 -2
- package/public/assets/MessageBubble-C7L3IeeL.js +3 -0
- package/public/assets/MessageBubble-C7L3IeeL.js.map +1 -0
- package/public/assets/{PlanGraphPanel-CynI6Jkn.js → PlanGraphPanel-Dxh2Cd0B.js} +2 -2
- package/public/assets/{PlanGraphPanel-CynI6Jkn.js.map → PlanGraphPanel-Dxh2Cd0B.js.map} +1 -1
- package/public/assets/{activity-CXduH3OT.js → activity-f0LLy20u.js} +2 -2
- package/public/assets/{activity-CXduH3OT.js.map → activity-f0LLy20u.js.map} +1 -1
- package/public/assets/{agent-profiles-jialTp_S.js → agent-profiles-DvktAPVq.js} +2 -2
- package/public/assets/{agent-profiles-jialTp_S.js.map → agent-profiles-DvktAPVq.js.map} +1 -1
- package/public/assets/{agents-Y6d4dgr2.js → agents-BjnKBLy7.js} +2 -2
- package/public/assets/{agents-Y6d4dgr2.js.map → agents-BjnKBLy7.js.map} +1 -1
- package/public/assets/{analytics-M0Pty7sl.js → analytics-CLxDxrYr.js} +2 -2
- package/public/assets/{analytics-M0Pty7sl.js.map → analytics-CLxDxrYr.js.map} +1 -1
- package/public/assets/{automation-CG_q2Xpq.js → automation-CC6F9m_t.js} +2 -2
- package/public/assets/{automation-CG_q2Xpq.js.map → automation-CC6F9m_t.js.map} +1 -1
- package/public/assets/{branch-state-badge-D_BTP8-I.js → branch-state-badge-DGA_hkH_.js} +2 -2
- package/public/assets/{branch-state-badge-D_BTP8-I.js.map → branch-state-badge-DGA_hkH_.js.map} +1 -1
- package/public/assets/{channels-BrfLaQ0U.js → channels-c244tNlf.js} +2 -2
- package/public/assets/{channels-BrfLaQ0U.js.map → channels-c244tNlf.js.map} +1 -1
- package/public/assets/chat-5PirKNMc.js +2 -0
- package/public/assets/chat-5PirKNMc.js.map +1 -0
- package/public/assets/{connectors-BPy5G-NJ.js → connectors-B2HKhNAR.js} +2 -2
- package/public/assets/{connectors-BPy5G-NJ.js.map → connectors-B2HKhNAR.js.map} +1 -1
- package/public/assets/display-LAWaAboe.js +3 -0
- package/public/assets/{display-CWEz0zy_.js.map → display-LAWaAboe.js.map} +1 -1
- package/public/assets/formatted-body-impl-tscpWWyb.js +3 -0
- package/public/assets/formatted-body-impl-tscpWWyb.js.map +1 -0
- package/public/assets/index-F6MG4ocQ.css +2 -0
- package/public/assets/{index-D3ie3rwt.js → index-U-4HIYmF.js} +10 -10
- package/public/assets/index-U-4HIYmF.js.map +1 -0
- package/public/assets/{insights-Zd0e-vd4.js → insights-lc2QtO8O.js} +2 -2
- package/public/assets/{insights-Zd0e-vd4.js.map → insights-lc2QtO8O.js.map} +1 -1
- package/public/assets/{integrations-BYZbQndN.js → integrations-Qup4mhkH.js} +2 -2
- package/public/assets/{integrations-BYZbQndN.js.map → integrations-Qup4mhkH.js.map} +1 -1
- package/public/assets/{maintenance-DezsNPI4.js → maintenance-7Dy1fhTR.js} +2 -2
- package/public/assets/{maintenance-DezsNPI4.js.map → maintenance-7Dy1fhTR.js.map} +1 -1
- package/public/assets/{managed-agents-OdkCKQ7D.js → managed-agents-Ot7Yygpe.js} +2 -2
- package/public/assets/{managed-agents-OdkCKQ7D.js.map → managed-agents-Ot7Yygpe.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BqC6Fi5e.js → markdown-preview-impl-CzDWgMwj.js} +2 -2
- package/public/assets/{markdown-preview-impl-BqC6Fi5e.js.map → markdown-preview-impl-CzDWgMwj.js.map} +1 -1
- package/public/assets/{memory-BfFPcQcB.js → memory-CjAUbmt2.js} +2 -2
- package/public/assets/{memory-BfFPcQcB.js.map → memory-CjAUbmt2.js.map} +1 -1
- package/public/assets/{messages-ClkBcpYi.js → messages-B-pXamWZ.js} +2 -2
- package/public/assets/messages-B-pXamWZ.js.map +1 -0
- package/public/assets/orchestrators-BzAfrK0L.js +2 -0
- package/public/assets/{orchestrators-DyCvsN6E.js.map → orchestrators-BzAfrK0L.js.map} +1 -1
- package/public/assets/{overview-232eKGjk.js → overview-5mxLbpO7.js} +2 -2
- package/public/assets/{overview-232eKGjk.js.map → overview-5mxLbpO7.js.map} +1 -1
- package/public/assets/{pairs-CBymrP2X.js → pairs-DAfV42IZ.js} +2 -2
- package/public/assets/{pairs-CBymrP2X.js.map → pairs-DAfV42IZ.js.map} +1 -1
- package/public/assets/{projects-BvVx_AIE.js → projects-DH957G-z.js} +2 -2
- package/public/assets/{projects-BvVx_AIE.js.map → projects-DH957G-z.js.map} +1 -1
- package/public/assets/{security-FQMqi91P.js → security-ImaJC3Tp.js} +2 -2
- package/public/assets/{security-FQMqi91P.js.map → security-ImaJC3Tp.js.map} +1 -1
- package/public/assets/{select-SZYujtKA.js → select-Dp7SHtmp.js} +2 -2
- package/public/assets/{select-SZYujtKA.js.map → select-Dp7SHtmp.js.map} +1 -1
- package/public/assets/settings-CqEiOkw4.js +4 -0
- package/public/assets/settings-CqEiOkw4.js.map +1 -0
- package/public/assets/store-DSmm2NpG.js +9 -0
- package/public/assets/store-DSmm2NpG.js.map +1 -0
- package/public/assets/{tasks-DCc4hYMY.js → tasks-CWb1BuUk.js} +2 -2
- package/public/assets/{tasks-DCc4hYMY.js.map → tasks-CWb1BuUk.js.map} +1 -1
- package/public/assets/{teams-BjXD5Fs8.js → teams-SqRZA2bO.js} +2 -2
- package/public/assets/{teams-BjXD5Fs8.js.map → teams-SqRZA2bO.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BmlEy9zo.js → terminal-viewer-impl-_E8At_tx.js} +2 -2
- package/public/assets/{terminal-viewer-impl-BmlEy9zo.js.map → terminal-viewer-impl-_E8At_tx.js.map} +1 -1
- package/public/assets/{work-queue-BODxiiDI.js → work-queue-Cw55y2bR.js} +2 -2
- package/public/assets/{work-queue-BODxiiDI.js.map → work-queue-Cw55y2bR.js.map} +1 -1
- package/public/assets/{workspaces-Bly4o-i4.js → workspaces-I7KmXqPQ.js} +2 -2
- package/public/assets/{workspaces-Bly4o-i4.js.map → workspaces-I7KmXqPQ.js.map} +1 -1
- package/public/index.html +4 -4
- package/runner/src/adapter.ts +1 -1
- package/src/agent-issue-repo.ts +29 -0
- package/src/branch-landed.ts +44 -2
- package/src/config.ts +5 -0
- package/src/context-advisory.ts +93 -15
- package/src/context-threshold-config.ts +14 -2
- package/src/db/migrations.ts +6 -0
- package/src/db/projects.ts +24 -6
- package/src/db/schema.ts +1 -0
- package/src/maintenance/jobs.ts +11 -0
- package/src/mcp-project-tools.ts +2 -0
- package/src/provider-catalog-store.ts +35 -0
- package/src/routes/agents.ts +3 -2
- package/src/routes/provider-config.ts +27 -0
- package/src/services/git-remote.ts +46 -0
- package/src/services/issue-lifecycle.ts +21 -38
- package/src/services/project.ts +16 -2
- package/src/services/register-agent.ts +7 -1
- package/src/services/spawn-registration-watchdog.ts +100 -0
- package/src/settings/registry.ts +2 -0
- package/src/sse.ts +4 -3
- package/public/assets/MessageBubble-CMZo7OAy.js +0 -2
- package/public/assets/MessageBubble-CMZo7OAy.js.map +0 -1
- package/public/assets/chat-DC-eLkyI.js +0 -2
- package/public/assets/chat-DC-eLkyI.js.map +0 -1
- package/public/assets/display-CWEz0zy_.js +0 -3
- package/public/assets/formatted-body-impl-CEJs5Ehz.js +0 -3
- package/public/assets/formatted-body-impl-CEJs5Ehz.js.map +0 -1
- package/public/assets/index-BY23dkkh.css +0 -2
- package/public/assets/index-D3ie3rwt.js.map +0 -1
- package/public/assets/messages-ClkBcpYi.js.map +0 -1
- package/public/assets/orchestrators-DyCvsN6E.js +0 -2
- package/public/assets/settings-CVPjbE2O.js +0 -4
- package/public/assets/settings-CVPjbE2O.js.map +0 -1
- package/public/assets/store-BfICn_iR.js +0 -9
- package/public/assets/store-BfICn_iR.js.map +0 -1
package/src/context-advisory.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { deriveContextThresholdFromWindowTokens, resolveProviderContextThreshold, type ProviderModelCatalogEntry, type ResolvedProviderContextThreshold } from "agent-relay-sdk/provider-catalog";
|
|
2
|
+
import { isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
|
|
3
|
+
import { getConfig, getNotificationsConfig } from "./config-store";
|
|
4
|
+
import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
|
|
2
5
|
import { getDb } from "./db/connection.ts";
|
|
3
6
|
import { emitRelayEvent } from "./events";
|
|
4
7
|
import { routeNotification } from "./notification-router";
|
|
8
|
+
import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
5
9
|
import type { AgentCard, Command, ContextState } from "./types";
|
|
6
10
|
|
|
7
11
|
interface ContextAdvisoryInput {
|
|
@@ -10,7 +14,17 @@ interface ContextAdvisoryInput {
|
|
|
10
14
|
percent: number;
|
|
11
15
|
windowTokens: number;
|
|
12
16
|
band: number;
|
|
17
|
+
stage: ContextAdvisoryStage;
|
|
18
|
+
selfCompactPercent: number;
|
|
19
|
+
warnPercent: number;
|
|
13
20
|
hardBackstopPercent: number;
|
|
21
|
+
thresholdSource: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type ContextAdvisoryStage = "warn" | "self-compact";
|
|
25
|
+
|
|
26
|
+
interface EffectiveContextThreshold extends ResolvedProviderContextThreshold {
|
|
27
|
+
source: "model" | "global" | "window";
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
/**
|
|
@@ -25,7 +39,11 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
|
|
|
25
39
|
percent: input.percent,
|
|
26
40
|
windowTokens: input.windowTokens,
|
|
27
41
|
band: input.band,
|
|
42
|
+
stage: input.stage,
|
|
43
|
+
selfCompactPercent: input.selfCompactPercent,
|
|
44
|
+
warnPercent: input.warnPercent,
|
|
28
45
|
hardBackstopPercent: input.hardBackstopPercent,
|
|
46
|
+
thresholdSource: input.thresholdSource,
|
|
29
47
|
};
|
|
30
48
|
|
|
31
49
|
emitRelayEvent({
|
|
@@ -45,19 +63,29 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
|
|
|
45
63
|
scope: { agentId: input.agentId },
|
|
46
64
|
recipients: [input.agentId],
|
|
47
65
|
message: {
|
|
48
|
-
subject: "Context threshold reached",
|
|
49
|
-
body:
|
|
66
|
+
subject: input.stage === "warn" ? "Context threshold warning" : "Context self-compact threshold reached",
|
|
67
|
+
body: contextAdvisoryBody(input),
|
|
50
68
|
payload,
|
|
51
69
|
replyExpected: false,
|
|
52
70
|
},
|
|
53
71
|
});
|
|
54
72
|
}
|
|
55
73
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
function contextAdvisoryBody(input: ContextAdvisoryInput): string {
|
|
75
|
+
const usage = `${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band)`;
|
|
76
|
+
const threshold = `resolved self-compact threshold ${input.selfCompactPercent}% (${input.thresholdSource})`;
|
|
77
|
+
if (input.stage === "warn") {
|
|
78
|
+
return `Context warning: ${usage}. You are approaching the ${threshold}; if the current task is at a clean seam, prepare to call relay_compact_and_resume before continuing.`;
|
|
79
|
+
}
|
|
80
|
+
return `Context advisory: ${usage}. You are over the ${threshold}; call relay_compact_and_resume at the next clean seam between tasks or waves, not mid-edit. The ${input.hardBackstopPercent}% hard backstop may request compaction if usage keeps climbing.`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function contextAdvisoryBand(utilization: number, threshold: EffectiveContextThreshold): { band: number; stage: ContextAdvisoryStage } | null {
|
|
84
|
+
if (!Number.isFinite(utilization) || utilization < threshold.warnUtilization) return null;
|
|
85
|
+
if (utilization >= threshold.selfCompactUtilization) {
|
|
86
|
+
return { band: utilizationPercent(threshold.selfCompactUtilization), stage: "self-compact" };
|
|
87
|
+
}
|
|
88
|
+
return { band: utilizationPercent(threshold.warnUtilization), stage: "warn" };
|
|
61
89
|
}
|
|
62
90
|
|
|
63
91
|
export function handleContextPressure(input: {
|
|
@@ -71,9 +99,9 @@ export function handleContextPressure(input: {
|
|
|
71
99
|
if (!context || agent.status === "busy") return false;
|
|
72
100
|
if (context.lifecycleState === "compacting" || context.lifecycleState === "hibernating") return false;
|
|
73
101
|
if (typeof context.utilization !== "number" || !Number.isFinite(context.utilization)) return false;
|
|
74
|
-
const threshold =
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
102
|
+
const threshold = effectiveContextThreshold(agent);
|
|
103
|
+
const advisory = contextAdvisoryBand(context.utilization, threshold);
|
|
104
|
+
if (advisory === null) {
|
|
77
105
|
if (context.lastContextAdvisoryBand !== undefined || context.lastContextAdvisoryAt !== undefined) {
|
|
78
106
|
persistContextState(agent.id, clearContextAdvisory(context));
|
|
79
107
|
}
|
|
@@ -83,7 +111,7 @@ export function handleContextPressure(input: {
|
|
|
83
111
|
input.compact("context-hard-backstop");
|
|
84
112
|
return true;
|
|
85
113
|
}
|
|
86
|
-
if (context.lastContextAdvisoryBand === band) return false;
|
|
114
|
+
if (context.lastContextAdvisoryBand === advisory.band) return false;
|
|
87
115
|
const tokens = finiteNumber(context.tokensUsed);
|
|
88
116
|
const windowTokens = finiteNumber(context.tokensMax);
|
|
89
117
|
if (tokens === undefined || windowTokens === undefined) return false;
|
|
@@ -92,13 +120,55 @@ export function handleContextPressure(input: {
|
|
|
92
120
|
tokens,
|
|
93
121
|
percent: Math.round(context.utilization * 100),
|
|
94
122
|
windowTokens,
|
|
95
|
-
band,
|
|
96
|
-
|
|
123
|
+
band: advisory.band,
|
|
124
|
+
stage: advisory.stage,
|
|
125
|
+
warnPercent: utilizationPercent(threshold.warnUtilization),
|
|
126
|
+
selfCompactPercent: utilizationPercent(threshold.selfCompactUtilization),
|
|
127
|
+
hardBackstopPercent: utilizationPercent(threshold.hardBackstopUtilization),
|
|
128
|
+
thresholdSource: threshold.source,
|
|
97
129
|
});
|
|
98
|
-
persistContextState(agent.id, { ...context, lastContextAdvisoryBand: band, lastContextAdvisoryAt: now() });
|
|
130
|
+
persistContextState(agent.id, { ...context, lastContextAdvisoryBand: advisory.band, lastContextAdvisoryAt: now() });
|
|
99
131
|
return true;
|
|
100
132
|
}
|
|
101
133
|
|
|
134
|
+
function effectiveContextThreshold(agent: AgentCard): EffectiveContextThreshold {
|
|
135
|
+
const model = findAgentCatalogModel(agent);
|
|
136
|
+
const windowTokens = model?.limits?.contextWindowTokens?.value
|
|
137
|
+
?? finiteNumber(agent.providerCapabilities?.context?.windowTokens)
|
|
138
|
+
?? finiteNumber(agent.context?.tokensMax);
|
|
139
|
+
if (model?.contextThreshold) {
|
|
140
|
+
return { ...resolveProviderContextThreshold(model), source: "model" };
|
|
141
|
+
}
|
|
142
|
+
const global = explicitContextThresholdConfig();
|
|
143
|
+
if (global) return global;
|
|
144
|
+
return { ...deriveContextThresholdFromWindowTokens(windowTokens), source: "window" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function explicitContextThresholdConfig(): EffectiveContextThreshold | undefined {
|
|
148
|
+
const entry = getConfig("notifications", "default");
|
|
149
|
+
if (!entry || !isRecord(entry.value) || !isRecord(entry.value.contextThreshold)) return undefined;
|
|
150
|
+
const global = validateContextThresholdConfig({ ...DEFAULT_CONTEXT_THRESHOLD_CONFIG, ...entry.value.contextThreshold });
|
|
151
|
+
const selfCompactUtilization = global.selfCompactUtilization ?? global.advisoryUtilization;
|
|
152
|
+
return {
|
|
153
|
+
selfCompactUtilization,
|
|
154
|
+
warnUtilization: global.warnUtilization ?? Math.max(0, roundUtilization(selfCompactUtilization - 0.1)),
|
|
155
|
+
hardBackstopUtilization: global.hardBackstopUtilization,
|
|
156
|
+
source: "global",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function findAgentCatalogModel(agent: AgentCard): ProviderModelCatalogEntry | undefined {
|
|
161
|
+
const runtime = agent.providerCapabilities?.model;
|
|
162
|
+
const provider = runtime?.provider;
|
|
163
|
+
if (!provider || !(SPAWN_PROVIDERS as readonly string[]).includes(provider)) return undefined;
|
|
164
|
+
const catalog = effectiveProviderCatalogList().find((entry) => entry.provider === provider);
|
|
165
|
+
if (!catalog) return undefined;
|
|
166
|
+
const ids = [runtime.alias, runtime.id, runtime.providerModel].filter((value): value is string => Boolean(value));
|
|
167
|
+
const lowerIds = new Set(ids.map((id) => id.toLowerCase()));
|
|
168
|
+
return catalog.models.find((model) => ids.includes(model.alias) || ids.includes(model.providerModel))
|
|
169
|
+
?? catalog.models.find((model) => lowerIds.has(model.alias.toLowerCase()) || lowerIds.has(model.providerModel.toLowerCase()));
|
|
170
|
+
}
|
|
171
|
+
|
|
102
172
|
function clearContextAdvisory(context: ContextState): ContextState {
|
|
103
173
|
const { lastContextAdvisoryBand: _band, lastContextAdvisoryAt: _at, ...rest } = context;
|
|
104
174
|
return rest;
|
|
@@ -112,6 +182,14 @@ function finiteNumber(value: unknown): number | undefined {
|
|
|
112
182
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
113
183
|
}
|
|
114
184
|
|
|
185
|
+
function utilizationPercent(value: number): number {
|
|
186
|
+
return Math.round(value * 100);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function roundUtilization(value: number): number {
|
|
190
|
+
return Math.round(value * 1000) / 1000;
|
|
191
|
+
}
|
|
192
|
+
|
|
115
193
|
function formatTokens(value: number): string {
|
|
116
194
|
return Math.round(value).toLocaleString("en-US");
|
|
117
195
|
}
|
|
@@ -14,13 +14,25 @@ export function validateContextThresholdConfig(value: unknown): NotificationsCon
|
|
|
14
14
|
const bandSize = cleanFraction(input.bandSize, "contextThreshold.bandSize", DEFAULT_CONTEXT_THRESHOLD_CONFIG.bandSize);
|
|
15
15
|
if (bandSize <= 0) throw new ValidationError("contextThreshold.bandSize must be greater than 0");
|
|
16
16
|
const advisoryUtilization = cleanFraction(input.advisoryUtilization, "contextThreshold.advisoryUtilization", DEFAULT_CONTEXT_THRESHOLD_CONFIG.advisoryUtilization);
|
|
17
|
+
const selfCompactUtilization = input.selfCompactUtilization === undefined || input.selfCompactUtilization === null
|
|
18
|
+
? undefined
|
|
19
|
+
: cleanFraction(input.selfCompactUtilization, "contextThreshold.selfCompactUtilization", advisoryUtilization);
|
|
20
|
+
const warnUtilization = input.warnUtilization === undefined || input.warnUtilization === null
|
|
21
|
+
? undefined
|
|
22
|
+
: cleanFraction(input.warnUtilization, "contextThreshold.warnUtilization", Math.max(0, (selfCompactUtilization ?? advisoryUtilization) - 0.1));
|
|
17
23
|
const hardBackstopUtilization = cleanFraction(input.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization", DEFAULT_CONTEXT_THRESHOLD_CONFIG.hardBackstopUtilization);
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
const compactUtilization = selfCompactUtilization ?? advisoryUtilization;
|
|
25
|
+
if (warnUtilization !== undefined && warnUtilization >= compactUtilization) {
|
|
26
|
+
throw new ValidationError("contextThreshold.warnUtilization must be less than contextThreshold.selfCompactUtilization");
|
|
27
|
+
}
|
|
28
|
+
if (hardBackstopUtilization <= compactUtilization) {
|
|
29
|
+
throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.selfCompactUtilization");
|
|
20
30
|
}
|
|
21
31
|
return {
|
|
22
32
|
enabled: input.enabled === undefined ? DEFAULT_CONTEXT_THRESHOLD_CONFIG.enabled : cleanBoolean(input.enabled, "contextThreshold.enabled"),
|
|
23
33
|
advisoryUtilization,
|
|
34
|
+
...(selfCompactUtilization !== undefined ? { selfCompactUtilization } : {}),
|
|
35
|
+
...(warnUtilization !== undefined ? { warnUtilization } : {}),
|
|
24
36
|
hardBackstopUtilization,
|
|
25
37
|
bandSize,
|
|
26
38
|
};
|
package/src/db/migrations.ts
CHANGED
|
@@ -453,6 +453,12 @@ export function applyMigrations(): void {
|
|
|
453
453
|
getDb().run("ALTER TABLE provider_model_overrides ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0");
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
// Migration: projects.issue_repo (#533) — per-project `owner/repo` for #NNN linkify/land-close.
|
|
457
|
+
const projectCols = (getDb().query("PRAGMA table_info(projects)").all() as any[]).map((c: any) => c.name);
|
|
458
|
+
if (!projectCols.includes("issue_repo")) {
|
|
459
|
+
getDb().run("ALTER TABLE projects ADD COLUMN issue_repo TEXT");
|
|
460
|
+
}
|
|
461
|
+
|
|
456
462
|
// Migration: orchestrators.api_url
|
|
457
463
|
const orchCols = getDb().query("PRAGMA table_info(orchestrators)").all() as any[];
|
|
458
464
|
const orchColNames = orchCols.map((c: any) => c.name);
|
package/src/db/projects.ts
CHANGED
|
@@ -14,6 +14,7 @@ interface ProjectRow {
|
|
|
14
14
|
title: string | null;
|
|
15
15
|
description: string | null;
|
|
16
16
|
tenant_id: string | null;
|
|
17
|
+
issue_repo: string | null;
|
|
17
18
|
status: Project["status"];
|
|
18
19
|
created_at: number;
|
|
19
20
|
updated_at: number;
|
|
@@ -36,6 +37,7 @@ function rowToProject(row: ProjectRow): Project {
|
|
|
36
37
|
...(row.title ? { title: row.title } : {}),
|
|
37
38
|
...(row.description ? { description: row.description } : {}),
|
|
38
39
|
...(row.tenant_id ? { tenantId: row.tenant_id } : {}),
|
|
40
|
+
...(row.issue_repo ? { issueRepo: row.issue_repo } : {}),
|
|
39
41
|
status: row.status,
|
|
40
42
|
createdAt: row.created_at,
|
|
41
43
|
updatedAt: row.updated_at,
|
|
@@ -81,25 +83,29 @@ interface EnsureProjectInput {
|
|
|
81
83
|
title?: string;
|
|
82
84
|
description?: string;
|
|
83
85
|
tenantId?: string;
|
|
86
|
+
/** Explicit `owner/repo` override (#533); when set it wins over the git-remote-derived
|
|
87
|
+
* default. A bare derived value is filled separately via setProjectIssueRepo(onlyIfNull). */
|
|
88
|
+
issueRepo?: string;
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
/** Get-or-create a project by slug. The create-on-first-use analog of ensureTeam;
|
|
87
|
-
* updates title/description/tenant on an existing project only when provided. */
|
|
92
|
+
* updates title/description/tenant/issueRepo on an existing project only when provided. */
|
|
88
93
|
export function ensureProject(input: EnsureProjectInput): Project {
|
|
89
94
|
const slug = input.slug.trim();
|
|
90
95
|
if (!slug) throw new ValidationError("slug required");
|
|
91
96
|
const existing = getProjectBySlug(slug);
|
|
92
97
|
const now = Date.now();
|
|
93
98
|
if (existing) {
|
|
94
|
-
if (input.title !== undefined || input.description !== undefined || input.tenantId !== undefined) {
|
|
99
|
+
if (input.title !== undefined || input.description !== undefined || input.tenantId !== undefined || input.issueRepo !== undefined) {
|
|
95
100
|
getDb().query(`
|
|
96
101
|
UPDATE projects
|
|
97
|
-
SET title = ?, description = ?, tenant_id = ?, updated_at = ?
|
|
102
|
+
SET title = ?, description = ?, tenant_id = ?, issue_repo = ?, updated_at = ?
|
|
98
103
|
WHERE id = ?
|
|
99
104
|
`).run(
|
|
100
105
|
input.title ?? existing.title ?? null,
|
|
101
106
|
input.description ?? existing.description ?? null,
|
|
102
107
|
input.tenantId ?? existing.tenantId ?? null,
|
|
108
|
+
input.issueRepo ?? existing.issueRepo ?? null,
|
|
103
109
|
now,
|
|
104
110
|
existing.id,
|
|
105
111
|
);
|
|
@@ -109,12 +115,24 @@ export function ensureProject(input: EnsureProjectInput): Project {
|
|
|
109
115
|
}
|
|
110
116
|
const id = projectId();
|
|
111
117
|
getDb().query(`
|
|
112
|
-
INSERT INTO projects (id, slug, title, description, tenant_id, status, created_at, updated_at)
|
|
113
|
-
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
|
|
114
|
-
`).run(id, slug, input.title ?? null, input.description ?? null, input.tenantId ?? null, now, now);
|
|
118
|
+
INSERT INTO projects (id, slug, title, description, tenant_id, issue_repo, status, created_at, updated_at)
|
|
119
|
+
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
|
120
|
+
`).run(id, slug, input.title ?? null, input.description ?? null, input.tenantId ?? null, input.issueRepo ?? null, now, now);
|
|
115
121
|
return getProject(id)!;
|
|
116
122
|
}
|
|
117
123
|
|
|
124
|
+
/** Set (or clear, with null) a project's `issueRepo` (#533). With `onlyIfNull`, a value is
|
|
125
|
+
* written only when none is set yet — the mechanism for the git-remote AUTO-DERIVE to fill
|
|
126
|
+
* a fresh project without ever clobbering an explicit override or a prior derivation. */
|
|
127
|
+
export function setProjectIssueRepo(id: string, issueRepo: string | null, opts: { onlyIfNull?: boolean } = {}): Project | null {
|
|
128
|
+
const project = getProject(id);
|
|
129
|
+
if (!project) return null;
|
|
130
|
+
if (opts.onlyIfNull && project.issueRepo) return project;
|
|
131
|
+
const value = issueRepo?.trim() || null;
|
|
132
|
+
getDb().query("UPDATE projects SET issue_repo = ?, updated_at = ? WHERE id = ?").run(value, Date.now(), id);
|
|
133
|
+
return getProject(id);
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
/** Register a root path for a project (idempotent — UNIQUE(project_id, root_path)). */
|
|
119
137
|
export function addProjectRoot(id: string, rootPath: string): ProjectRoot[] {
|
|
120
138
|
const project = getProject(id);
|
package/src/db/schema.ts
CHANGED
|
@@ -207,6 +207,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
207
207
|
title TEXT,
|
|
208
208
|
description TEXT,
|
|
209
209
|
tenant_id TEXT,
|
|
210
|
+
issue_repo TEXT,
|
|
210
211
|
status TEXT NOT NULL DEFAULT 'active',
|
|
211
212
|
created_at INTEGER NOT NULL,
|
|
212
213
|
updated_at INTEGER NOT NULL
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "../sse";
|
|
34
34
|
import { pruneExpiredTokenRecords } from "../token-db";
|
|
35
35
|
import { reapTransientAgents } from "../services/transient-agent-reaper";
|
|
36
|
+
import { reapUnregisteredSpawnCommands } from "../services/spawn-registration-watchdog";
|
|
36
37
|
import { resumeRateLimitedAgents } from "../services/rate-limit-resume";
|
|
37
38
|
import { MERGE_PAUSE_MAINTENANCE_JOB } from "../merge-pause-maintenance";
|
|
38
39
|
import {
|
|
@@ -125,6 +126,16 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
125
126
|
return { expiredCommandIds: commands.map((command) => command.id) };
|
|
126
127
|
},
|
|
127
128
|
},
|
|
129
|
+
{
|
|
130
|
+
id: "spawn-registration-watchdog",
|
|
131
|
+
title: "Spawn registration watchdog",
|
|
132
|
+
description: "Fail parented spawn commands whose runner process started but never registered with Relay.",
|
|
133
|
+
intervalMs: REAP_INTERVAL_MS,
|
|
134
|
+
runOnStart: true,
|
|
135
|
+
handler() {
|
|
136
|
+
return reapUnregisteredSpawnCommands();
|
|
137
|
+
},
|
|
138
|
+
},
|
|
128
139
|
{
|
|
129
140
|
id: "bus-agent-expiration",
|
|
130
141
|
title: "Bus agent expiration",
|
package/src/mcp-project-tools.ts
CHANGED
|
@@ -62,6 +62,7 @@ export const PROJECT_TOOLS = [
|
|
|
62
62
|
title: { type: "string" },
|
|
63
63
|
description: { type: "string" },
|
|
64
64
|
tenantId: { type: "string" },
|
|
65
|
+
issueRepo: { type: "string", description: "Explicit owner/repo for #NNN linkify; overrides the git-remote-derived default (#533)." },
|
|
65
66
|
},
|
|
66
67
|
required: ["slug", "rootPath"],
|
|
67
68
|
additionalProperties: false,
|
|
@@ -273,6 +274,7 @@ export function relayProjectTool(auth: McpAuthLike, name: string, args: Record<s
|
|
|
273
274
|
title: cleanString(args.title, "title", { max: 240 }),
|
|
274
275
|
description: cleanString(args.description, "description", { max: 16_000 }),
|
|
275
276
|
tenantId: cleanString(args.tenantId, "tenantId", { max: 200 }),
|
|
277
|
+
issueRepo: cleanString(args.issueRepo, "issueRepo", { max: 200 }),
|
|
276
278
|
});
|
|
277
279
|
return { project, roots };
|
|
278
280
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
providerCatalogList,
|
|
3
3
|
type ProviderCatalogEntry,
|
|
4
|
+
type ProviderContextThreshold,
|
|
4
5
|
type ProviderModelCatalogEntry,
|
|
5
6
|
} from "agent-relay-sdk/provider-catalog";
|
|
6
7
|
import { SPAWN_PROVIDERS } from "agent-relay-sdk";
|
|
@@ -145,6 +146,9 @@ function normalizeOverrideInput(input: ProviderModelOverrideInput): ProviderMode
|
|
|
145
146
|
if (!input.entry.providerModel?.trim()) throw new ValidationError("provider model is required");
|
|
146
147
|
if (!Array.isArray(input.entry.efforts)) throw new ValidationError("model efforts must be an array");
|
|
147
148
|
const entry = cloneModelEntry({ ...input.entry, alias });
|
|
149
|
+
const contextThreshold = normalizeContextThreshold(entry.contextThreshold);
|
|
150
|
+
if (contextThreshold) entry.contextThreshold = contextThreshold;
|
|
151
|
+
else delete entry.contextThreshold;
|
|
148
152
|
delete entry.disabled;
|
|
149
153
|
return {
|
|
150
154
|
...input,
|
|
@@ -183,3 +187,34 @@ function cloneProviderEntry(entry: ProviderCatalogEntry): ProviderCatalogEntry {
|
|
|
183
187
|
function cloneModelEntry(entry: ProviderModelCatalogEntry): ProviderModelCatalogEntry {
|
|
184
188
|
return JSON.parse(JSON.stringify(entry)) as ProviderModelCatalogEntry;
|
|
185
189
|
}
|
|
190
|
+
|
|
191
|
+
function normalizeContextThreshold(value: unknown): ProviderContextThreshold | undefined {
|
|
192
|
+
if (value === undefined || value === null) return undefined;
|
|
193
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError("contextThreshold must be an object");
|
|
194
|
+
const input = value as Record<string, unknown>;
|
|
195
|
+
const selfCompactUtilization = cleanFraction(input.selfCompactUtilization, "contextThreshold.selfCompactUtilization");
|
|
196
|
+
const warnUtilization = input.warnUtilization === undefined || input.warnUtilization === null
|
|
197
|
+
? undefined
|
|
198
|
+
: cleanFraction(input.warnUtilization, "contextThreshold.warnUtilization");
|
|
199
|
+
const hardBackstopUtilization = input.hardBackstopUtilization === undefined || input.hardBackstopUtilization === null
|
|
200
|
+
? undefined
|
|
201
|
+
: cleanFraction(input.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization");
|
|
202
|
+
if (warnUtilization !== undefined && warnUtilization >= selfCompactUtilization) {
|
|
203
|
+
throw new ValidationError("contextThreshold.warnUtilization must be less than contextThreshold.selfCompactUtilization");
|
|
204
|
+
}
|
|
205
|
+
if (hardBackstopUtilization !== undefined && hardBackstopUtilization <= selfCompactUtilization) {
|
|
206
|
+
throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.selfCompactUtilization");
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
selfCompactUtilization,
|
|
210
|
+
...(warnUtilization !== undefined ? { warnUtilization } : {}),
|
|
211
|
+
...(hardBackstopUtilization !== undefined ? { hardBackstopUtilization } : {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function cleanFraction(value: unknown, field: string): number {
|
|
216
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
217
|
+
throw new ValidationError(`${field} must be a number between 0 and 1`);
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
220
|
+
}
|
package/src/routes/agents.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, type Handler } from "./_shared";
|
|
3
3
|
import { ValidationError, deleteAgent, getAgent, getAgentTimeline, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
|
|
4
4
|
import { attachBranchState, attachBranchStates } from "../agent-branch-state";
|
|
5
|
+
import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
|
|
5
6
|
import { cleanStringArray } from "../validation";
|
|
6
7
|
import { clearActiveMemories, memoryBroker } from "../memory-service";
|
|
7
8
|
import { emitAgentRemoved, emitAgentStatus } from "../sse";
|
|
@@ -44,7 +45,7 @@ export const getAgents: Handler = (req) => {
|
|
|
44
45
|
const tag = url.searchParams.get("tag") ?? undefined;
|
|
45
46
|
const machine = url.searchParams.get("machine") ?? undefined;
|
|
46
47
|
const status = url.searchParams.get("status") ?? undefined;
|
|
47
|
-
return json(attachBranchStates(listAgents({ tag, machine, status })));
|
|
48
|
+
return json(attachIssueRepos(attachBranchStates(listAgents({ tag, machine, status }))));
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
export const findAgents: Handler = (req) => {
|
|
@@ -57,7 +58,7 @@ export const findAgents: Handler = (req) => {
|
|
|
57
58
|
|
|
58
59
|
export const getAgentById: Handler = (_req, params) => {
|
|
59
60
|
const agent = getAgent(params.id!);
|
|
60
|
-
return agent ? json(attachBranchState(agent)) : error("agent not found", 404);
|
|
61
|
+
return agent ? json(attachIssueRepo(attachBranchState(agent))) : error("agent not found", 404);
|
|
61
62
|
};
|
|
62
63
|
|
|
63
64
|
export const getAgentReplyObligations: Handler = (_req, params) => {
|
|
@@ -139,16 +139,43 @@ function cleanProviderModelEntry(alias: string, body: Record<string, unknown>):
|
|
|
139
139
|
efforts,
|
|
140
140
|
...(defaultEffort ? { defaultEffort } : {}),
|
|
141
141
|
...(isRecord(source.limits) ? { limits: source.limits as ProviderModelCatalogEntry["limits"] } : {}),
|
|
142
|
+
...(isRecord(source.contextThreshold) ? { contextThreshold: cleanProviderContextThreshold(source.contextThreshold) } : {}),
|
|
142
143
|
...(isRecord(source.capabilities) ? { capabilities: source.capabilities as unknown as ProviderModelCatalogEntry["capabilities"] } : {}),
|
|
143
144
|
};
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
function cleanProviderContextThreshold(source: Record<string, unknown>): NonNullable<ProviderModelCatalogEntry["contextThreshold"]> {
|
|
148
|
+
const selfCompactUtilization = optionalFraction(source.selfCompactUtilization, "contextThreshold.selfCompactUtilization");
|
|
149
|
+
if (selfCompactUtilization === undefined) throw new ValidationError("contextThreshold.selfCompactUtilization required");
|
|
150
|
+
const warnUtilization = optionalFraction(source.warnUtilization, "contextThreshold.warnUtilization");
|
|
151
|
+
const hardBackstopUtilization = optionalFraction(source.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization");
|
|
152
|
+
if (warnUtilization !== undefined && warnUtilization >= selfCompactUtilization) {
|
|
153
|
+
throw new ValidationError("contextThreshold.warnUtilization must be less than contextThreshold.selfCompactUtilization");
|
|
154
|
+
}
|
|
155
|
+
if (hardBackstopUtilization !== undefined && hardBackstopUtilization <= selfCompactUtilization) {
|
|
156
|
+
throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.selfCompactUtilization");
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
selfCompactUtilization,
|
|
160
|
+
...(warnUtilization !== undefined ? { warnUtilization } : {}),
|
|
161
|
+
...(hardBackstopUtilization !== undefined ? { hardBackstopUtilization } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
146
165
|
function optionalBoolean(value: unknown, field: string): boolean | undefined {
|
|
147
166
|
if (value === undefined || value === null) return undefined;
|
|
148
167
|
if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
|
|
149
168
|
return value;
|
|
150
169
|
}
|
|
151
170
|
|
|
171
|
+
function optionalFraction(value: unknown, field: string): number | undefined {
|
|
172
|
+
if (value === undefined || value === null) return undefined;
|
|
173
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
174
|
+
throw new ValidationError(`${field} must be a number between 0 and 1`);
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
152
179
|
export const deleteCommandById: Handler = (_req, params) => {
|
|
153
180
|
if (!deleteCommand(params.id!)) return error("command not found or cannot be canceled", 404);
|
|
154
181
|
const command = getCommand(params.id!);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Shared git-remote → `owner/repo` resolution (#533). The SINGLE home for deriving a
|
|
2
|
+
// GitHub `owner/repo` from a repo root's `origin` remote. Used by two callers that must
|
|
3
|
+
// not drift: issue-lifecycle land-close (which repo a bare `#NNN` in a commit subject
|
|
4
|
+
// closes) AND project auto-seed (a project's `issueRepo` for chat #NNN linkify). Promoted
|
|
5
|
+
// out of issue-lifecycle.ts where it was private — see the DRY rule in CLAUDE.md.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `owner/repo` out of any git remote URL form: `git@github.com:owner/repo.git`,
|
|
9
|
+
* `https://github.com/owner/repo.git`, `ssh://git@host/owner/repo`, trailing slash/`.git`
|
|
10
|
+
* optional. Pure — exported for tests.
|
|
11
|
+
*/
|
|
12
|
+
export function parseOwnerRepoFromRemote(url: string | undefined): string | undefined {
|
|
13
|
+
if (!url) return undefined;
|
|
14
|
+
const match = url.trim().match(/[:/]([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
|
|
15
|
+
return match?.[1];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let remoteRepoCache: Map<string, string | undefined> | undefined;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `owner/repo` from a repo root's `origin` remote, cached per root. Works from any
|
|
22
|
+
* subdirectory of the repo (`git -C` walks up). Returns undefined when the dir isn't a
|
|
23
|
+
* git repo, has no `origin`, or the URL doesn't parse.
|
|
24
|
+
*/
|
|
25
|
+
export function repoFromGitRemote(repoRoot: string | undefined): string | undefined {
|
|
26
|
+
if (!repoRoot) return undefined;
|
|
27
|
+
if (!remoteRepoCache) remoteRepoCache = new Map();
|
|
28
|
+
if (remoteRepoCache.has(repoRoot)) return remoteRepoCache.get(repoRoot);
|
|
29
|
+
let repo: string | undefined;
|
|
30
|
+
try {
|
|
31
|
+
const proc = Bun.spawnSync(["git", "-C", repoRoot, "remote", "get-url", "origin"], {
|
|
32
|
+
stdin: "ignore",
|
|
33
|
+
stdout: "pipe",
|
|
34
|
+
stderr: "ignore",
|
|
35
|
+
});
|
|
36
|
+
if (proc.exitCode === 0) repo = parseOwnerRepoFromRemote(Buffer.from(proc.stdout).toString("utf8"));
|
|
37
|
+
} catch {
|
|
38
|
+
repo = undefined;
|
|
39
|
+
}
|
|
40
|
+
remoteRepoCache.set(repoRoot, repo);
|
|
41
|
+
return repo;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function __resetRemoteRepoCacheForTests(): void {
|
|
45
|
+
remoteRepoCache = undefined;
|
|
46
|
+
}
|
|
@@ -10,6 +10,11 @@ import { getIssueRef, resolveIssueRefByExternalKey, resolveIssueRefFromString, u
|
|
|
10
10
|
import { associateIssue } from "../db/issue-associations";
|
|
11
11
|
import { resolveIssueTrackerAdapter } from "../issue-tracker";
|
|
12
12
|
import { defaultIssueRepo } from "../config";
|
|
13
|
+
// `repoFromGitRemote` / `parseOwnerRepoFromRemote` live in the shared ./git-remote module
|
|
14
|
+
// (#533) so project auto-seed reuses the exact same logic. Re-exported so this module's
|
|
15
|
+
// existing importers and tests keep their entry point.
|
|
16
|
+
import { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests } from "./git-remote";
|
|
17
|
+
export { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests };
|
|
13
18
|
import type { IssueTrackerAdapter } from "../issue-tracker";
|
|
14
19
|
import { recordAndEmitIssueActivity } from "./issue-activity";
|
|
15
20
|
import type { IssueAssociationEntityType, IssueProvider, IssueRef, WorkspaceRecord } from "../types";
|
|
@@ -22,6 +27,7 @@ interface LandedIssueLifecycleInput {
|
|
|
22
27
|
workspace: LifecycleWorkspace;
|
|
23
28
|
mergedSha?: string;
|
|
24
29
|
subject?: string;
|
|
30
|
+
commitSubjects?: string[];
|
|
25
31
|
source?: string;
|
|
26
32
|
/**
|
|
27
33
|
* The land happened via a pull request (the squash/merge subject carries a
|
|
@@ -79,42 +85,6 @@ function logFailure(opts: IssueLifecycleOptions, message: string): void {
|
|
|
79
85
|
else console.warn(message);
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
/**
|
|
83
|
-
* `owner/repo` out of any git remote URL form: `git@github.com:owner/repo.git`,
|
|
84
|
-
* `https://github.com/owner/repo.git`, `ssh://git@host/owner/repo`, trailing slash/`.git`
|
|
85
|
-
* optional. Pure — exported for tests.
|
|
86
|
-
*/
|
|
87
|
-
export function parseOwnerRepoFromRemote(url: string | undefined): string | undefined {
|
|
88
|
-
if (!url) return undefined;
|
|
89
|
-
const match = url.trim().match(/[:/]([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
|
|
90
|
-
return match?.[1];
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
let remoteRepoCache: Map<string, string | undefined> | undefined;
|
|
94
|
-
|
|
95
|
-
function repoFromGitRemote(repoRoot: string | undefined): string | undefined {
|
|
96
|
-
if (!repoRoot) return undefined;
|
|
97
|
-
if (!remoteRepoCache) remoteRepoCache = new Map();
|
|
98
|
-
if (remoteRepoCache.has(repoRoot)) return remoteRepoCache.get(repoRoot);
|
|
99
|
-
let repo: string | undefined;
|
|
100
|
-
try {
|
|
101
|
-
const proc = Bun.spawnSync(["git", "-C", repoRoot, "remote", "get-url", "origin"], {
|
|
102
|
-
stdin: "ignore",
|
|
103
|
-
stdout: "pipe",
|
|
104
|
-
stderr: "ignore",
|
|
105
|
-
});
|
|
106
|
-
if (proc.exitCode === 0) repo = parseOwnerRepoFromRemote(Buffer.from(proc.stdout).toString("utf8"));
|
|
107
|
-
} catch {
|
|
108
|
-
repo = undefined;
|
|
109
|
-
}
|
|
110
|
-
remoteRepoCache.set(repoRoot, repo);
|
|
111
|
-
return repo;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function __resetRemoteRepoCacheForTests(): void {
|
|
115
|
-
remoteRepoCache = undefined;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
88
|
/**
|
|
119
89
|
* The repo a bare `#NNN` in a land's commit subject resolves against. Derived per-workspace
|
|
120
90
|
* from its own git remote so a single relay serving many repos closes the RIGHT repo's issue;
|
|
@@ -146,6 +116,18 @@ function issueRefMap(refs: IssueRef[]): IssueRef[] {
|
|
|
146
116
|
return [...out.values()];
|
|
147
117
|
}
|
|
148
118
|
|
|
119
|
+
function landedSubjectTexts(input: LandedIssueLifecycleInput): string[] {
|
|
120
|
+
const out: string[] = [];
|
|
121
|
+
const seen = new Set<string>();
|
|
122
|
+
for (const text of [...(input.commitSubjects ?? []), input.subject]) {
|
|
123
|
+
const cleaned = cleanText(text);
|
|
124
|
+
if (!cleaned || seen.has(cleaned)) continue;
|
|
125
|
+
seen.add(cleaned);
|
|
126
|
+
out.push(cleaned);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
149
131
|
function associatedIssueRefs(workspace: LifecycleWorkspace): IssueRef[] {
|
|
150
132
|
const refs: IssueRef[] = [];
|
|
151
133
|
const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
|
|
@@ -211,13 +193,14 @@ function resolveIssueRefsForLand(input: LandedIssueLifecycleInput): { source: La
|
|
|
211
193
|
const associated = associatedIssueRefs(input.workspace);
|
|
212
194
|
if (associated.length > 0) return { source: "association", refs: associated };
|
|
213
195
|
const fallbackDefaultRepo = landDefaultRepo(input.workspace);
|
|
214
|
-
const
|
|
196
|
+
const subjects = landedSubjectTexts(input);
|
|
197
|
+
const keyworded = issueRefMap(subjects.flatMap((subject) => parseClosingIssueRefs(subject, fallbackDefaultRepo)));
|
|
215
198
|
if (keyworded.length > 0) return { source: "commit", refs: keyworded };
|
|
216
199
|
// Bare `(#NNN)` is closing intent in relay-land mode (no PR), but the SAME shape is the
|
|
217
200
|
// PR number GitHub appends to a squash subject — never close that. Suppress on PR lands;
|
|
218
201
|
// those rely on GitHub-native keywords + durable association instead (#482).
|
|
219
202
|
if (input.prLand) return { source: "commit", refs: [] };
|
|
220
|
-
return { source: "backlink", refs:
|
|
203
|
+
return { source: "backlink", refs: issueRefMap(subjects.flatMap((subject) => parseBacklinkIssueRefs(subject, fallbackDefaultRepo))) };
|
|
221
204
|
}
|
|
222
205
|
|
|
223
206
|
function closeAuditComment(input: LandedIssueLifecycleInput): string {
|
package/src/services/project.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
-
import { addProjectRoot, ensureProject, resolveProjectForCwd, rootsForProject } from "../db";
|
|
3
|
+
import { addProjectRoot, ensureProject, resolveProjectForCwd, rootsForProject, setProjectIssueRepo } from "../db";
|
|
4
4
|
import { ingestClaudeMd } from "../project-ingest";
|
|
5
|
+
import { repoFromGitRemote } from "./git-remote";
|
|
5
6
|
import type { Project, ProjectRoot } from "../types";
|
|
6
7
|
|
|
7
8
|
interface EnsureProjectRootInput {
|
|
@@ -10,6 +11,8 @@ interface EnsureProjectRootInput {
|
|
|
10
11
|
title?: string;
|
|
11
12
|
description?: string;
|
|
12
13
|
tenantId?: string;
|
|
14
|
+
/** Explicit `owner/repo` override (#533); wins over the git-remote-derived default. */
|
|
15
|
+
issueRepo?: string;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
interface EnsureProjectRootResult {
|
|
@@ -33,8 +36,19 @@ export function ensureProjectRoot(input: EnsureProjectRootInput): EnsureProjectR
|
|
|
33
36
|
...(input.title !== undefined ? { title: input.title } : {}),
|
|
34
37
|
...(input.description !== undefined ? { description: input.description } : {}),
|
|
35
38
|
...(input.tenantId !== undefined ? { tenantId: input.tenantId } : {}),
|
|
39
|
+
...(input.issueRepo !== undefined ? { issueRepo: input.issueRepo } : {}),
|
|
36
40
|
});
|
|
37
|
-
|
|
41
|
+
const roots = addProjectRoot(project.id, input.rootPath);
|
|
42
|
+
// Auto-derive issueRepo from the root's git remote (#533) when not explicitly provided.
|
|
43
|
+
// `onlyIfNull` ensures an explicit override (or earlier derivation) is never clobbered.
|
|
44
|
+
if (input.issueRepo === undefined && !project.issueRepo) {
|
|
45
|
+
const derived = repoFromGitRemote(input.rootPath);
|
|
46
|
+
if (derived) {
|
|
47
|
+
const updated = setProjectIssueRepo(project.id, derived, { onlyIfNull: true });
|
|
48
|
+
if (updated) return { project: updated, roots };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { project, roots };
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
export function ingestProjectClaudeMd(projectId: string, authorId: string): ClaudeMdIngestResult {
|