agent-relay-server 0.61.1 → 0.62.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 +479 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-CX63ZV4X.js → MessageBubble-BP0Ym3xb.js} +2 -2
- package/public/assets/{MessageBubble-CX63ZV4X.js.map → MessageBubble-BP0Ym3xb.js.map} +1 -1
- package/public/assets/PlanGraphPanel-DKc3gdou.js +2 -0
- package/public/assets/PlanGraphPanel-DKc3gdou.js.map +1 -0
- package/public/assets/{activity-2xPOScu_.js → activity-CRLG6ICe.js} +2 -2
- package/public/assets/{activity-2xPOScu_.js.map → activity-CRLG6ICe.js.map} +1 -1
- package/public/assets/{agent-profiles-BfGTTbeY.js → agent-profiles-DItyEkXP.js} +2 -2
- package/public/assets/{agent-profiles-BfGTTbeY.js.map → agent-profiles-DItyEkXP.js.map} +1 -1
- package/public/assets/{agents-Bg3WOBRV.js → agents-5mKiZ1rz.js} +2 -2
- package/public/assets/{agents-Bg3WOBRV.js.map → agents-5mKiZ1rz.js.map} +1 -1
- package/public/assets/{analytics-De0cEZGx.js → analytics-J5pPcS-v.js} +3 -3
- package/public/assets/{analytics-De0cEZGx.js.map → analytics-J5pPcS-v.js.map} +1 -1
- package/public/assets/{automation-CHxhzq2t.js → automation-BG2HWK5N.js} +2 -2
- package/public/assets/{automation-CHxhzq2t.js.map → automation-BG2HWK5N.js.map} +1 -1
- package/public/assets/{badge-DhLyCPag.js → badge-CZhIdlTJ.js} +2 -2
- package/public/assets/{badge-DhLyCPag.js.map → badge-CZhIdlTJ.js.map} +1 -1
- package/public/assets/{branch-state-badge-DSyWKluD.js → branch-state-badge-BZMoxs65.js} +2 -2
- package/public/assets/{branch-state-badge-DSyWKluD.js.map → branch-state-badge-BZMoxs65.js.map} +1 -1
- package/public/assets/{button-K20wAZG1.js → button-2taQeNHW.js} +2 -2
- package/public/assets/{button-K20wAZG1.js.map → button-2taQeNHW.js.map} +1 -1
- package/public/assets/card-DzIsAI41.js +2 -0
- package/public/assets/{card-CcZ9hLI7.js.map → card-DzIsAI41.js.map} +1 -1
- package/public/assets/channels-B2PeqiRT.js +2 -0
- package/public/assets/{channels-BG0Nib7y.js.map → channels-B2PeqiRT.js.map} +1 -1
- package/public/assets/chat-CLRJVagA.js +2 -0
- package/public/assets/{chat-CjP_crSl.js.map → chat-CLRJVagA.js.map} +1 -1
- package/public/assets/{connectors-DWh7MwV_.js → connectors-Bzvkxsgo.js} +2 -2
- package/public/assets/{connectors-DWh7MwV_.js.map → connectors-Bzvkxsgo.js.map} +1 -1
- package/public/assets/copy-button-HEkwi6xX.js +2 -0
- package/public/assets/{copy-button-eF3cKp4Q.js.map → copy-button-HEkwi6xX.js.map} +1 -1
- package/public/assets/display-DzNvOu1j.js.map +1 -1
- package/public/assets/dist-1rXN5FN9.js +2 -0
- package/public/assets/{dist-YpHfnnIA.js.map → dist-1rXN5FN9.js.map} +1 -1
- package/public/assets/{dist-1dhfdhm9.js → dist-AbEQuFoK.js} +2 -2
- package/public/assets/{dist-1dhfdhm9.js.map → dist-AbEQuFoK.js.map} +1 -1
- package/public/assets/{dist-COQuunEv.js → dist-B7p1zkvf.js} +2 -2
- package/public/assets/{dist-COQuunEv.js.map → dist-B7p1zkvf.js.map} +1 -1
- package/public/assets/{dist-Ci5Y37nj.js → dist-CG10hM1-.js} +2 -2
- package/public/assets/{dist-Ci5Y37nj.js.map → dist-CG10hM1-.js.map} +1 -1
- package/public/assets/dist-Cfm5_zPB.js +2 -0
- package/public/assets/{dist-qd198ib9.js.map → dist-Cfm5_zPB.js.map} +1 -1
- package/public/assets/{dist-BrQsqBbc.js → dist-Cgw85UcE.js} +2 -2
- package/public/assets/{dist-BrQsqBbc.js.map → dist-Cgw85UcE.js.map} +1 -1
- package/public/assets/{dist-CoROafNg.js → dist-D3_TJKTC.js} +2 -2
- package/public/assets/{dist-CoROafNg.js.map → dist-D3_TJKTC.js.map} +1 -1
- package/public/assets/{dist-I-MFUv51.js → dist-DQkLq0hs.js} +2 -2
- package/public/assets/{dist-I-MFUv51.js.map → dist-DQkLq0hs.js.map} +1 -1
- package/public/assets/{dist-UQGKwqZH.js → dist-D_sl7u_f.js} +2 -2
- package/public/assets/{dist-UQGKwqZH.js.map → dist-D_sl7u_f.js.map} +1 -1
- package/public/assets/{dist-yGOq6sB3.js → dist-Ilb3r1im.js} +2 -2
- package/public/assets/{dist-yGOq6sB3.js.map → dist-Ilb3r1im.js.map} +1 -1
- package/public/assets/dist-Pf_3Gf38.js +2 -0
- package/public/assets/{dist-BLBF7qqr.js.map → dist-Pf_3Gf38.js.map} +1 -1
- package/public/assets/{es2015-CRxz9lOj.js → es2015-GSepNaIS.js} +2 -2
- package/public/assets/{es2015-CRxz9lOj.js.map → es2015-GSepNaIS.js.map} +1 -1
- package/public/assets/{formatted-body-impl-u0W_hWYA.js → formatted-body-impl-QqHxdMGP.js} +2 -2
- package/public/assets/{formatted-body-impl-u0W_hWYA.js.map → formatted-body-impl-QqHxdMGP.js.map} +1 -1
- package/public/assets/index-CCp1V35c.js +22 -0
- package/public/assets/index-CCp1V35c.js.map +1 -0
- package/public/assets/index-DiR-fqy_.css +2 -0
- package/public/assets/{input-BHYNpXAG.js → input-BsNqlONJ.js} +2 -2
- package/public/assets/{input-BHYNpXAG.js.map → input-BsNqlONJ.js.map} +1 -1
- package/public/assets/{insights-CAF9Hi5D.js → insights-D-EyDfoZ.js} +2 -2
- package/public/assets/{insights-CAF9Hi5D.js.map → insights-D-EyDfoZ.js.map} +1 -1
- package/public/assets/{integrations-CG9qyHUf.js → integrations-D1kc-yNi.js} +2 -2
- package/public/assets/{integrations-CG9qyHUf.js.map → integrations-D1kc-yNi.js.map} +1 -1
- package/public/assets/{lib-Df4aph5u.js → lib-bXPu6xCL.js} +2 -2
- package/public/assets/{lib-Df4aph5u.js.map → lib-bXPu6xCL.js.map} +1 -1
- package/public/assets/lucide-react-DWYjU-AF.js +9 -0
- package/public/assets/{lucide-react-L2UxFDic.js.map → lucide-react-DWYjU-AF.js.map} +1 -1
- package/public/assets/{maintenance-DgHA35ve.js → maintenance-COqID7aq.js} +2 -2
- package/public/assets/{maintenance-DgHA35ve.js.map → maintenance-COqID7aq.js.map} +1 -1
- package/public/assets/{managed-agents-Rk6ghKR9.js → managed-agents-b4B8qewQ.js} +2 -2
- package/public/assets/{managed-agents-Rk6ghKR9.js.map → managed-agents-b4B8qewQ.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-NWMtJdYz.js → markdown-preview-impl-D7o8jS07.js} +2 -2
- package/public/assets/{markdown-preview-impl-NWMtJdYz.js.map → markdown-preview-impl-D7o8jS07.js.map} +1 -1
- package/public/assets/memory-Q0qF7_i3.js +2 -0
- package/public/assets/{memory-DamwWa9A.js.map → memory-Q0qF7_i3.js.map} +1 -1
- package/public/assets/{messages--NU-YIL6.js → messages-m4EpkK6s.js} +2 -2
- package/public/assets/{messages--NU-YIL6.js.map → messages-m4EpkK6s.js.map} +1 -1
- package/public/assets/{orchestrators-DQnIn6qx.js → orchestrators-CnAtGfUN.js} +2 -2
- package/public/assets/{orchestrators-DQnIn6qx.js.map → orchestrators-CnAtGfUN.js.map} +1 -1
- package/public/assets/{overview-DCmNgXDf.js → overview-D7CF88ik.js} +2 -2
- package/public/assets/{overview-DCmNgXDf.js.map → overview-D7CF88ik.js.map} +1 -1
- package/public/assets/pairs-Q6PlFJtH.js +2 -0
- package/public/assets/{pairs-DNELeC9P.js.map → pairs-Q6PlFJtH.js.map} +1 -1
- package/public/assets/{react-dom-CrP_AtcL.js → react-dom-CPghDLpB.js} +2 -2
- package/public/assets/{react-dom-CrP_AtcL.js.map → react-dom-CPghDLpB.js.map} +1 -1
- package/public/assets/{security-Dd3Kh0oC.js → security-BeV-6SdA.js} +2 -2
- package/public/assets/{security-Dd3Kh0oC.js.map → security-BeV-6SdA.js.map} +1 -1
- package/public/assets/{select-DQbeJZX0.js → select-Dchc0_Jg.js} +2 -2
- package/public/assets/{select-DQbeJZX0.js.map → select-Dchc0_Jg.js.map} +1 -1
- package/public/assets/{settings-BMAJrBIH.js → settings-hPwL-31u.js} +3 -3
- package/public/assets/{settings-BMAJrBIH.js.map → settings-hPwL-31u.js.map} +1 -1
- package/public/assets/store-Buwkqv_7.js +9 -0
- package/public/assets/store-Buwkqv_7.js.map +1 -0
- package/public/assets/tasks-DKUMPEgk.js +2 -0
- package/public/assets/{tasks-BYR4L9GP.js.map → tasks-DKUMPEgk.js.map} +1 -1
- package/public/assets/teams-T5fFsdOX.js +2 -0
- package/public/assets/teams-T5fFsdOX.js.map +1 -0
- package/public/assets/{terminal-viewer-impl-B1YzfpE6.js → terminal-viewer-impl-Bk0u_Mav.js} +3 -3
- package/public/assets/{terminal-viewer-impl-B1YzfpE6.js.map → terminal-viewer-impl-Bk0u_Mav.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-DrRH_QzG.js → use-keyboard-viewport-NKw1O5gC.js} +2 -2
- package/public/assets/{use-keyboard-viewport-DrRH_QzG.js.map → use-keyboard-viewport-NKw1O5gC.js.map} +1 -1
- package/public/assets/{work-queue-x57vlgyZ.js → work-queue-D26jok09.js} +2 -2
- package/public/assets/{work-queue-x57vlgyZ.js.map → work-queue-D26jok09.js.map} +1 -1
- package/public/assets/{workspaces-CYm5bLg9.js → workspaces-BMj6zx23.js} +3 -3
- package/public/assets/{workspaces-CYm5bLg9.js.map → workspaces-BMj6zx23.js.map} +1 -1
- package/public/index.html +21 -21
- package/public/sw.js +32 -8
- package/src/automations.ts +40 -7
- package/src/branch-landed.ts +5 -0
- package/src/bus.ts +3 -0
- package/src/cli/workspace.ts +9 -17
- package/src/db/activity.ts +191 -3
- package/src/db/integrations.ts +1 -0
- package/src/db/issue-associations.ts +128 -0
- package/src/db/issue-lifecycle.ts +187 -0
- package/src/db/issue-refs.ts +254 -0
- package/src/db/mappers.ts +2 -0
- package/src/db/migrations.ts +47 -0
- package/src/db/schema.ts +2 -2
- package/src/issue-tracker/adapter.ts +24 -0
- package/src/issue-tracker/github.ts +136 -0
- package/src/issue-tracker/index.ts +13 -0
- package/src/issue-tracker/registry.ts +26 -0
- package/src/maintenance.ts +22 -169
- package/src/mcp-issue-activity-tools.ts +114 -0
- package/src/mcp-issue-association-tools.ts +130 -0
- package/src/mcp-issue-lifecycle-tools.ts +55 -0
- package/src/mcp-issue-ref-tools.ts +174 -0
- package/src/mcp-team-tools.ts +1 -1
- package/src/mcp.ts +3 -3
- package/src/provider-config-store.ts +30 -8
- package/src/routes/activity.ts +6 -1
- package/src/routes/commands.ts +29 -0
- package/src/routes/index.ts +19 -0
- package/src/routes/issue-activity.ts +78 -0
- package/src/routes/issue-associations.ts +76 -0
- package/src/routes/issue-lifecycle.ts +34 -0
- package/src/routes/issue-refs.ts +137 -0
- package/src/routes/orchestrator.ts +42 -1
- package/src/security.ts +8 -0
- package/src/services/issue-activity.ts +30 -0
- package/src/services/issue-associations.ts +72 -0
- package/src/services/issue-lifecycle.ts +308 -0
- package/src/services/issue-refs.ts +32 -0
- package/src/services/plan-graph.ts +40 -2
- package/src/services/team.ts +4 -0
- package/src/token-db.ts +3 -3
- package/src/workspace-actions.ts +8 -1
- package/src/workspace-auto-merge.ts +228 -0
- package/src/workspace-probe.ts +125 -0
- package/public/assets/PlanGraphPanel-Dby7wq9c.js +0 -2
- package/public/assets/PlanGraphPanel-Dby7wq9c.js.map +0 -1
- package/public/assets/card-CcZ9hLI7.js +0 -2
- package/public/assets/channels-BG0Nib7y.js +0 -2
- package/public/assets/chat-CjP_crSl.js +0 -2
- package/public/assets/copy-button-eF3cKp4Q.js +0 -2
- package/public/assets/dist-BLBF7qqr.js +0 -2
- package/public/assets/dist-YpHfnnIA.js +0 -2
- package/public/assets/dist-qd198ib9.js +0 -2
- package/public/assets/index-BwfVOzUG.js +0 -21
- package/public/assets/index-BwfVOzUG.js.map +0 -1
- package/public/assets/index-Dn_GDXbi.css +0 -2
- package/public/assets/lucide-react-L2UxFDic.js +0 -9
- package/public/assets/memory-DamwWa9A.js +0 -2
- package/public/assets/pairs-DNELeC9P.js +0 -2
- package/public/assets/store-B8xP6z6T.js +0 -9
- package/public/assets/store-B8xP6z6T.js.map +0 -1
- package/public/assets/tasks-BYR4L9GP.js +0 -2
- package/public/assets/teams-CoTifrq_.js +0 -2
- package/public/assets/teams-CoTifrq_.js.map +0 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
IssueTrackerAdapter,
|
|
3
|
+
IssueTrackerCloseOptions,
|
|
4
|
+
IssueTrackerReopenOptions,
|
|
5
|
+
IssueTrackerStatus,
|
|
6
|
+
} from "./adapter";
|
|
7
|
+
export {
|
|
8
|
+
createIssueTrackerRegistry,
|
|
9
|
+
issueTrackerRegistry,
|
|
10
|
+
resolveIssueTrackerAdapter,
|
|
11
|
+
type IssueTrackerAdapterMap,
|
|
12
|
+
type IssueTrackerRegistry,
|
|
13
|
+
} from "./registry";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IssueProvider, IssueRef } from "../types";
|
|
2
|
+
import type { IssueTrackerAdapter } from "./adapter";
|
|
3
|
+
import { githubIssueTrackerAdapter } from "./github";
|
|
4
|
+
|
|
5
|
+
export type IssueTrackerAdapterMap = Partial<Record<IssueProvider, IssueTrackerAdapter>>;
|
|
6
|
+
|
|
7
|
+
export interface IssueTrackerRegistry {
|
|
8
|
+
get(ref: IssueRef): IssueTrackerAdapter;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createIssueTrackerRegistry(adapters: IssueTrackerAdapterMap = {}): IssueTrackerRegistry {
|
|
12
|
+
const resolved: IssueTrackerAdapterMap = { github: githubIssueTrackerAdapter, ...adapters };
|
|
13
|
+
return {
|
|
14
|
+
get(ref: IssueRef): IssueTrackerAdapter {
|
|
15
|
+
const adapter = resolved[ref.provider];
|
|
16
|
+
if (!adapter) throw new Error(`No issue tracker adapter registered for provider ${ref.provider}`);
|
|
17
|
+
return adapter;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const issueTrackerRegistry = createIssueTrackerRegistry();
|
|
23
|
+
|
|
24
|
+
export function resolveIssueTrackerAdapter(ref: IssueRef): IssueTrackerAdapter {
|
|
25
|
+
return issueTrackerRegistry.get(ref);
|
|
26
|
+
}
|
package/src/maintenance.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { expireStaleBusAgents } from "./bus";
|
|
|
4
4
|
import { pruneOutbox } from "./bus-outbox";
|
|
5
5
|
import { createCommand, expireCommands } from "./commands-db";
|
|
6
6
|
import { DAY_MS, OFFLINE_PRUNE_MS, REAP_INTERVAL_MS, STALE_TTL_MS } from "./config";
|
|
7
|
-
import {
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
8
|
import {
|
|
9
9
|
createActivityEvent,
|
|
10
10
|
clearTeamZeroMembersSince,
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
expireQueuedMessages,
|
|
14
14
|
getAgent,
|
|
15
15
|
getDb,
|
|
16
|
-
getActiveMergePause,
|
|
17
16
|
getRepoSteward,
|
|
18
17
|
getWorkspace,
|
|
19
18
|
listActiveTeams,
|
|
@@ -23,7 +22,6 @@ import {
|
|
|
23
22
|
pruneOfflineAgents,
|
|
24
23
|
pruneOldMessages,
|
|
25
24
|
deleteWorkspace,
|
|
26
|
-
emitWorkspaceChange,
|
|
27
25
|
pruneOrphanedSharedWorkspaces,
|
|
28
26
|
reapStaleAgents,
|
|
29
27
|
reapStaleOrchestrators,
|
|
@@ -37,17 +35,15 @@ import {
|
|
|
37
35
|
updateWorkspaceStatus,
|
|
38
36
|
} from "./db";
|
|
39
37
|
import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
40
|
-
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
41
38
|
import { reconcileLandedWorkspace } from "./branch-landed";
|
|
42
|
-
import { isAwaitingReviewerApproval } from "./reviewer-pipeline";
|
|
43
39
|
import { preparePrCompletionScan, reconcileLandedPr } from "./workspace-pr-completion";
|
|
44
40
|
import { notifyAgentOffline } from "./agent-lifecycle-events";
|
|
45
|
-
import { workspaceActiveClaim } from "./workspace-claim";
|
|
46
41
|
import { reapOrphanedWorktrees } from "./workspace-orphans";
|
|
47
|
-
import { deriveBranchState,
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
42
|
+
import { deriveBranchState, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
43
|
+
import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "./workspace-probe";
|
|
44
|
+
import { autoMergeCleanFastForwards, wakeRepoSteward } from "./workspace-auto-merge";
|
|
45
|
+
import { errMessage } from "agent-relay-sdk";
|
|
46
|
+
import { getStewardConfig } from "./config-store";
|
|
51
47
|
import { emitRelayEvent } from "./events";
|
|
52
48
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
53
49
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
@@ -72,7 +68,12 @@ const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
72
68
|
const SCHEDULER_TICK_MS = 10_000;
|
|
73
69
|
const OUTBOX_RETENTION_MS = Number(process.env.AGENT_RELAY_OUTBOX_RETENTION_MS) || 60 * 60 * 1000;
|
|
74
70
|
const TOKEN_RECORD_RETENTION_SECONDS = Number(process.env.AGENT_RELAY_TOKEN_RECORD_RETENTION_SECONDS) || 7 * 24 * 60 * 60;
|
|
75
|
-
|
|
71
|
+
// #237: demoted from the 2-min PRIMARY merge-back driver to a slow self-healing
|
|
72
|
+
// backstop now that the work is event-driven (stop-hook freshness + on-ready/on-settle
|
|
73
|
+
// land). ~10 min + runOnStart on both the conflict scan and the auto-merge job: they
|
|
74
|
+
// almost never have work because events did it in seconds; they exist to catch dropped
|
|
75
|
+
// events, relay restarts, and direct human commits with no agent turn.
|
|
76
|
+
const CONFLICT_SCAN_INTERVAL_MS = Number(process.env.AGENT_RELAY_CONFLICT_SCAN_INTERVAL_MS) || 10 * 60 * 1000;
|
|
76
77
|
const WORKSPACE_RETENTION_MS = Number(process.env.AGENT_RELAY_WORKSPACE_RETENTION_MS) || DAY_MS;
|
|
77
78
|
const DB_MAINTENANCE_INTERVAL_MS = Number(process.env.AGENT_RELAY_DB_MAINTENANCE_INTERVAL_MS) || DAY_MS;
|
|
78
79
|
// VACUUM rewrites the whole file (auto_vacuum is off), so run it sparingly.
|
|
@@ -90,9 +91,6 @@ const WORKSPACE_ORPHAN_REAPER_INTERVAL_MS = Number(process.env.AGENT_RELAY_WORKS
|
|
|
90
91
|
// to require a manual or steward merge per repo. Read at call-time so operators can
|
|
91
92
|
// toggle it without a restart.
|
|
92
93
|
const WORKSPACE_AUTO_MERGE_INTERVAL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_AUTO_MERGE_INTERVAL_MS) || CONFLICT_SCAN_INTERVAL_MS;
|
|
93
|
-
// Don't re-wake the managed steward for the same workspace more than once per
|
|
94
|
-
// this window — a persistent conflict/behind row would otherwise re-ping every sweep.
|
|
95
|
-
const STEWARD_WAKE_COOLDOWN_MS = Number(process.env.AGENT_RELAY_STEWARD_WAKE_COOLDOWN_MS) || 10 * 60 * 1000;
|
|
96
94
|
// How long a stranded review_requested/conflict worktree (no online steward) may
|
|
97
95
|
// sit before escalating to the configured fallback target, and the durable
|
|
98
96
|
// escalation target itself (`policy:<name>`, `label:<name>`, `cap:<name>`, an
|
|
@@ -420,18 +418,18 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
420
418
|
{
|
|
421
419
|
id: "workspace-conflict-scan",
|
|
422
420
|
title: "Workspace conflict scan",
|
|
423
|
-
description: "
|
|
421
|
+
description: "Self-healing backstop (#237): probe worktrees for divergence/freshness and auto-flag conflicts. The turn-end stop-hook drives the common case; this catches dropped events, restarts, and direct human commits.",
|
|
424
422
|
intervalMs: CONFLICT_SCAN_INTERVAL_MS,
|
|
425
|
-
runOnStart:
|
|
423
|
+
runOnStart: true,
|
|
426
424
|
timeoutMs: 60 * 1000,
|
|
427
425
|
handler: scanWorkspaceConflicts,
|
|
428
426
|
},
|
|
429
427
|
{
|
|
430
428
|
id: "workspace-auto-merge",
|
|
431
429
|
title: "Workspace auto-merge",
|
|
432
|
-
description: "
|
|
430
|
+
description: "Self-healing backstop (#237): land any non-conflicting ready/review_requested worktree under the per-repo lease (rebasing when base moved on); only real/unknown conflicts go to the steward. The on-ready/on-settle event chain drives the common case; runOnStart reclaims rows stranded across a relay restart.",
|
|
433
431
|
intervalMs: WORKSPACE_AUTO_MERGE_INTERVAL_MS,
|
|
434
|
-
runOnStart:
|
|
432
|
+
runOnStart: true,
|
|
435
433
|
timeoutMs: 60 * 1000,
|
|
436
434
|
handler: autoMergeCleanFastForwards,
|
|
437
435
|
},
|
|
@@ -455,31 +453,6 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
455
453
|
},
|
|
456
454
|
];
|
|
457
455
|
|
|
458
|
-
function workspacePathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
|
|
459
|
-
if (!path || !baseDir) return false;
|
|
460
|
-
const rel = relative(resolve(baseDir), resolve(path));
|
|
461
|
-
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
async function fetchHostMergePreview(apiUrl: string, workspace: WorkspaceRecord, opts: { checkPr?: boolean } = {}): Promise<WorkspaceMergePreview | { available: false } | null> {
|
|
465
|
-
// `checkPr` costs a `gh` round-trip, so the caller opts in only where PR-merge ground
|
|
466
|
-
// truth is actionable (reconcile/land candidates), not for every badge-probe (#304).
|
|
467
|
-
const checkPr = opts.checkPr !== false;
|
|
468
|
-
const query = new URLSearchParams({ path: workspace.worktreePath, ...(checkPr ? { checkPr: "1" } : {}) });
|
|
469
|
-
if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
|
|
470
|
-
if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
|
|
471
|
-
const headers: Record<string, string> = {};
|
|
472
|
-
const token = process.env.AGENT_RELAY_TOKEN;
|
|
473
|
-
if (token) headers[RELAY_TOKEN_HEADER] = token;
|
|
474
|
-
try {
|
|
475
|
-
const res = await fetch(`${apiUrl}/api/workspace/merge-preview?${query.toString()}`, { headers, signal: AbortSignal.timeout(8_000) });
|
|
476
|
-
if (!res.ok) return null;
|
|
477
|
-
return await res.json() as WorkspaceMergePreview;
|
|
478
|
-
} catch {
|
|
479
|
-
return null;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
456
|
// Iterate active worktrees and ask the owning host whether each can still merge
|
|
484
457
|
// cleanly. Auto-flag `conflict` when a clean merge is no longer possible, and
|
|
485
458
|
// auto-clear conflicts we set ourselves once they resolve (restoring the prior
|
|
@@ -624,36 +597,6 @@ function reapZeroMemberTeams(): Record<string, unknown> {
|
|
|
624
597
|
return { activeTeams, zeroMemberTeams, trackedTeamIds, clearedTeamIds, reapedTeamIds, graceMs: grace };
|
|
625
598
|
}
|
|
626
599
|
|
|
627
|
-
// Wake the managed per-repo steward (issue #167) for a workspace it should handle:
|
|
628
|
-
// auto-provision the policy from global steward config, then queue a `policy:` wake
|
|
629
|
-
// message (which also spawns the on-demand agent now via onMessageForPolicy). Honors a
|
|
630
|
-
// per-workspace cooldown so a persistent conflict/behind row isn't re-pinged every sweep.
|
|
631
|
-
// Returns the steward policy name on a fresh wake, or null (disabled / no owner / cooled down).
|
|
632
|
-
function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | null {
|
|
633
|
-
const meta = ws.metadata as Record<string, unknown>;
|
|
634
|
-
const lastWoke = typeof meta.stewardWokenAt === "number" ? meta.stewardWokenAt : 0;
|
|
635
|
-
if (lastWoke && Date.now() - lastWoke < STEWARD_WAKE_COOLDOWN_MS) return null;
|
|
636
|
-
const policyName = ensureRepoSteward(ws.repoRoot);
|
|
637
|
-
if (!policyName) return null;
|
|
638
|
-
try {
|
|
639
|
-
// Obligation (ttl null by type default): a steward task must survive until claimed/handled.
|
|
640
|
-
routeNotification({
|
|
641
|
-
type: "workspace.steward_task",
|
|
642
|
-
scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
|
|
643
|
-
recipients: [`policy:${policyName}`],
|
|
644
|
-
message: {
|
|
645
|
-
subject: `Steward: ${ws.status} workspace needs attention`,
|
|
646
|
-
body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
|
|
647
|
-
payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
|
|
648
|
-
},
|
|
649
|
-
});
|
|
650
|
-
getLifecycleManager().onMessageForPolicy(policyName);
|
|
651
|
-
patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
|
|
652
|
-
return policyName;
|
|
653
|
-
} catch {
|
|
654
|
-
return null;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
600
|
|
|
658
601
|
async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
659
602
|
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
|
|
@@ -677,19 +620,12 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
677
620
|
|
|
678
621
|
const meta = ws.metadata as Record<string, unknown>;
|
|
679
622
|
|
|
680
|
-
// #236: stash the latest ahead/dirty counts so deriveBranchState can tell
|
|
681
|
-
// (⚪) from changes (🟡)
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
const nextAhead = p.ahead ?? 0;
|
|
687
|
-
const nextDirty = p.dirtyCount ?? 0;
|
|
688
|
-
if (meta.gitAhead !== nextAhead || meta.gitDirtyCount !== nextDirty) {
|
|
689
|
-
const before = deriveBranchState(ws);
|
|
690
|
-
const patched = patchWorkspaceMetadata(ws.id, { gitAhead: nextAhead, gitDirtyCount: nextDirty, gitProbedAt: Date.now() });
|
|
691
|
-
if (patched && deriveBranchState(patched) !== before) emitWorkspaceChange(patched);
|
|
692
|
-
}
|
|
623
|
+
// #236: stash the host's latest ahead/dirty counts so deriveBranchState can tell
|
|
624
|
+
// idle (⚪) from changes (🟡). This periodic pass is the self-healing backstop now
|
|
625
|
+
// that the turn-end stop-hook (`probeWorkspaceState`) flips the badge instantly
|
|
626
|
+
// (#237); both share `applyBranchFreshness` so the rule can't drift. Status-changing
|
|
627
|
+
// branches below own their own badge refresh via updateWorkspaceStatus.
|
|
628
|
+
applyBranchFreshness(ws, p);
|
|
693
629
|
|
|
694
630
|
// Landing wins over conflict checks. A merged PR can look like a no-op or conflict
|
|
695
631
|
// locally, so reconcile before the conflict-undefined skip and keep finalization in
|
|
@@ -777,89 +713,6 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
777
713
|
return { scanned: candidates.length, flagged, cleared, merged, prRecycled, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
|
|
778
714
|
}
|
|
779
715
|
|
|
780
|
-
// Deterministic auto-land (Layer 0, #167/#207/#242): land conflict-free ready
|
|
781
|
-
// work under the shared lease, rebasing when base moved; leave uncertainty to stewards.
|
|
782
|
-
async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
783
|
-
if (process.env.AGENT_RELAY_WORKSPACE_AUTO_MERGE === "0") return { skipped: "disabled" };
|
|
784
|
-
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
|
|
785
|
-
if (!orchestrators.length) return { scanned: 0, skipped: "no online orchestrators" };
|
|
786
|
-
|
|
787
|
-
const candidates = listWorkspaces().filter(
|
|
788
|
-
(ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && READY_TO_LAND_STATUSES.has(ws.status),
|
|
789
|
-
);
|
|
790
|
-
const stewardEnabled = getStewardConfig().enabled;
|
|
791
|
-
const merged: string[] = [];
|
|
792
|
-
const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
|
|
793
|
-
|
|
794
|
-
for (const ws of candidates) {
|
|
795
|
-
if (isAwaitingReviewerApproval(ws)) { heldAwaitingApproval.push(ws.id); continue; }
|
|
796
|
-
// A claimed workspace is being validated by a steward — don't race it (#208).
|
|
797
|
-
if (workspaceActiveClaim(ws)) { heldByClaim.push(ws.id); continue; }
|
|
798
|
-
if (getActiveMergePause(ws.repoRoot, ws.baseRef ?? "main")) { heldByPause.push(ws.id); continue; }
|
|
799
|
-
const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
|
|
800
|
-
if (!orch?.apiUrl) continue;
|
|
801
|
-
const preview = await fetchHostMergePreview(orch.apiUrl, ws);
|
|
802
|
-
if (!preview || (preview as { available?: false }).available === false) continue;
|
|
803
|
-
const p = preview as WorkspaceMergePreview;
|
|
804
|
-
if (p.error || p.missing) continue;
|
|
805
|
-
if (p.reason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
|
|
806
|
-
patchWorkspaceMetadata(ws.id, { lastLandSkipReason: p.reason, lastLandSkipAt: Date.now() });
|
|
807
|
-
} else if (ws.metadata.lastLandSkipReason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
|
|
808
|
-
patchWorkspaceMetadata(ws.id, { lastLandSkipReason: undefined, lastLandSkipAt: undefined });
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const ahead = p.unmergedAhead ?? p.ahead ?? 0;
|
|
812
|
-
const behind = p.behind ?? 0;
|
|
813
|
-
// Land anything that won't conflict — including a base that moved on (behind>0)
|
|
814
|
-
// but rebases cleanly. The merge-tree prediction already accounts for the moved
|
|
815
|
-
// base, and mergeRebaseFf rebases then aborts-to-conflict on a real replay
|
|
816
|
-
// conflict, so a too-optimistic prediction degrades safely to review_requested.
|
|
817
|
-
// Only a predicted conflict (true) or unknown state (undefined) goes to the steward.
|
|
818
|
-
// A no-op (#230) — nothing to land (ahead=0, clean) — is also landable: the host
|
|
819
|
-
// resolves it to a terminal `merged` status, draining the queue instead of waking
|
|
820
|
-
// the steward every sweep for a workspace with no work.
|
|
821
|
-
const canLand = p.noop === true || (p.conflict === false && ahead > 0);
|
|
822
|
-
if (!canLand) {
|
|
823
|
-
leftForSteward.push(ws.id);
|
|
824
|
-
if (stewardEnabled) {
|
|
825
|
-
const woke = wakeRepoSteward(ws, p.conflict === true ? "conflict" : "merge state unknown");
|
|
826
|
-
if (woke) wokeStewards.push(woke);
|
|
827
|
-
}
|
|
828
|
-
continue;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// Honor the operator's instance landing.strategy (#419-followup): when PR mode is on
|
|
832
|
-
// (`landing.strategy = "pr"`), autonomous worker lands must open a PR too — not silently
|
|
833
|
-
// direct-merge. Passing an explicit "rebase-ff" here SHADOWS the instance config inside
|
|
834
|
-
// requestWorkspaceMerge (explicit wins over instanceStrategy), which is the bug that made
|
|
835
|
-
// PR mode never engage for spawned workers. For "pr" we OMIT the explicit strategy so
|
|
836
|
-
// requestWorkspaceMerge resolves it from the config and defaults autoMerge to "on-green";
|
|
837
|
-
// the default "direct" keeps the historical deterministic rebase-ff (no behavior change).
|
|
838
|
-
const result = requestWorkspaceMerge(ws, getLandingConfig().strategy === "pr" ? { requestedBy: "auto-merge" } : { strategy: "rebase-ff", requestedBy: "auto-merge" });
|
|
839
|
-
if (!result.ok) {
|
|
840
|
-
// 409 = another merge holds this repo's lease this tick; retry next sweep.
|
|
841
|
-
heldByLease.push(ws.id);
|
|
842
|
-
continue;
|
|
843
|
-
}
|
|
844
|
-
emitCommand(result.command);
|
|
845
|
-
merged.push(ws.id);
|
|
846
|
-
createActivityEvent({
|
|
847
|
-
clientId: `workspace-auto-merge-${ws.id}-${Date.now()}`,
|
|
848
|
-
kind: "state",
|
|
849
|
-
title: p.noop ? "Workspace auto-resolving (no work to merge)" : behind > 0 ? "Workspace auto-merging (rebase)" : "Workspace auto-merging (clean fast-forward)",
|
|
850
|
-
body: p.noop
|
|
851
|
-
? `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (nothing to land — resolving to merged)`
|
|
852
|
-
: `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (${ahead} ahead${behind > 0 ? `, ${behind} behind — rebasing` : ", clean"})`,
|
|
853
|
-
meta: ws.branch ?? ws.id,
|
|
854
|
-
icon: "ti-git-merge",
|
|
855
|
-
view: "orchestrators",
|
|
856
|
-
metadata: { source: "server", maintenanceJobId: "workspace-auto-merge", workspaceId: ws.id, commandId: result.command.id, ahead },
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, leftForSteward, wokeStewards };
|
|
861
|
-
}
|
|
862
|
-
|
|
863
716
|
// Route a GC notification, swallowing failures (a stale/missing/misconfigured target must never
|
|
864
717
|
// break the GC sweep). Returns the target on success, null otherwise. These are all obligation-style
|
|
865
718
|
// workspace handoffs (null TTL by type default) — they must survive until the recipient acts.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { McpNotFoundError } from "./mcp-errors";
|
|
2
|
+
import { ValidationError } from "./db";
|
|
3
|
+
import { getIssueRef } from "./services/issue-refs";
|
|
4
|
+
import {
|
|
5
|
+
ISSUE_ACTIVITY_ACTIONS,
|
|
6
|
+
listAgentIssueActivity,
|
|
7
|
+
listIssueActivityAgents,
|
|
8
|
+
recordAndEmitIssueActivity,
|
|
9
|
+
type IssueActivityAction,
|
|
10
|
+
} from "./services/issue-activity";
|
|
11
|
+
import { cleanEnum, cleanMeta, cleanString, optionalEnum } from "./validation";
|
|
12
|
+
import { ISSUE_PROVIDERS, type IssueProvider } from "./types";
|
|
13
|
+
|
|
14
|
+
export const ISSUE_ACTIVITY_TOOLS = [
|
|
15
|
+
{
|
|
16
|
+
name: "relay_issue_activity_record",
|
|
17
|
+
description: "Record that an agent read, edited, or completed work for an IssueRef. Accepts issueRefId or a raw issueRef string such as #473 or owner/repo#473.",
|
|
18
|
+
requiredScopes: ["issue:write"],
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
agentId: { type: "string" },
|
|
23
|
+
action: { type: "string", enum: ISSUE_ACTIVITY_ACTIONS },
|
|
24
|
+
issueRefId: { type: "string" },
|
|
25
|
+
issueRef: { type: "string" },
|
|
26
|
+
ref: { type: "string" },
|
|
27
|
+
defaultRepo: { type: "string" },
|
|
28
|
+
repo: { type: "string" },
|
|
29
|
+
provider: { type: "string", enum: ISSUE_PROVIDERS },
|
|
30
|
+
clientId: { type: "string" },
|
|
31
|
+
title: { type: "string" },
|
|
32
|
+
body: { type: "string" },
|
|
33
|
+
metadata: { type: "object" },
|
|
34
|
+
},
|
|
35
|
+
required: ["agentId", "action"],
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "relay_issue_activity_by_issue",
|
|
41
|
+
description: "List agents and actions recorded for one internal IssueRef id.",
|
|
42
|
+
requiredScopes: ["issue:read"],
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
issueRefId: { type: "string" },
|
|
47
|
+
limit: { type: "integer", minimum: 1, maximum: 500 },
|
|
48
|
+
},
|
|
49
|
+
required: ["issueRefId"],
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "relay_issue_activity_by_agent",
|
|
55
|
+
description: "List IssueRefs and actions touched by one agent.",
|
|
56
|
+
requiredScopes: ["issue:read"],
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
agentId: { type: "string" },
|
|
61
|
+
limit: { type: "integer", minimum: 1, maximum: 500 },
|
|
62
|
+
},
|
|
63
|
+
required: ["agentId"],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
|
|
68
|
+
|
|
69
|
+
type IssueActivityToolAuth = { kind: string; actor: string; scopes?: string[] };
|
|
70
|
+
|
|
71
|
+
function providerArg(value: unknown): IssueProvider | undefined {
|
|
72
|
+
return optionalEnum(value, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function actionArg(value: unknown): IssueActivityAction {
|
|
76
|
+
return cleanEnum(value, "action", ISSUE_ACTIVITY_ACTIONS) as IssueActivityAction;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function limitArg(value: unknown): number | undefined {
|
|
80
|
+
if (value === undefined || value === null) return undefined;
|
|
81
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > 500) {
|
|
82
|
+
throw new ValidationError("limit must be between 1 and 500");
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function relayIssueActivityTool(_auth: IssueActivityToolAuth, name: string, args: Record<string, unknown>): Record<string, unknown> {
|
|
88
|
+
if (name === "relay_issue_activity_record") {
|
|
89
|
+
const event = recordAndEmitIssueActivity({
|
|
90
|
+
agentId: cleanString(args.agentId, "agentId", { required: true, max: 200 })!,
|
|
91
|
+
action: actionArg(args.action),
|
|
92
|
+
issueRefId: cleanString(args.issueRefId, "issueRefId", { max: 120 }),
|
|
93
|
+
issueRef: cleanString(args.issueRef ?? args.ref, "issueRef", { max: 1000 }),
|
|
94
|
+
defaultRepo: cleanString(args.defaultRepo ?? args.repo, "defaultRepo", { max: 500 }),
|
|
95
|
+
provider: providerArg(args.provider),
|
|
96
|
+
clientId: cleanString(args.clientId, "clientId", { max: 240 }),
|
|
97
|
+
title: cleanString(args.title, "title", { max: 200 }),
|
|
98
|
+
body: cleanString(args.body, "body", { max: 1000 }),
|
|
99
|
+
metadata: cleanMeta(args.metadata),
|
|
100
|
+
});
|
|
101
|
+
return { event };
|
|
102
|
+
}
|
|
103
|
+
if (name === "relay_issue_activity_by_issue") {
|
|
104
|
+
const issueRefId = cleanString(args.issueRefId, "issueRefId", { required: true, max: 120 })!;
|
|
105
|
+
const issueRef = getIssueRef(issueRefId);
|
|
106
|
+
if (!issueRef) throw new McpNotFoundError("issue ref not found");
|
|
107
|
+
return { issueRef, agents: listIssueActivityAgents(issueRef.id, { limit: limitArg(args.limit) }) };
|
|
108
|
+
}
|
|
109
|
+
if (name === "relay_issue_activity_by_agent") {
|
|
110
|
+
const agentId = cleanString(args.agentId, "agentId", { required: true, max: 200 })!;
|
|
111
|
+
return { agentId, issues: listAgentIssueActivity(agentId, { limit: limitArg(args.limit) }) };
|
|
112
|
+
}
|
|
113
|
+
throw new ValidationError(`unknown tool: ${name}`);
|
|
114
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { associateIssueRef, listIssueEntities, listIssueRefsForEntity, removeIssueAssociation, resolveAssociationIssueRef } from "./services/issue-associations";
|
|
2
|
+
import { cleanEnum, cleanString, optionalEnum } from "./validation";
|
|
3
|
+
import { ISSUE_ASSOCIATION_ENTITY_TYPES, ISSUE_PROVIDERS, isRecord, type IssueAssociationEntityType, type IssueProvider } from "./types";
|
|
4
|
+
import { ValidationError } from "./db";
|
|
5
|
+
|
|
6
|
+
export const ISSUE_ASSOCIATION_TOOLS = [
|
|
7
|
+
{
|
|
8
|
+
name: "relay_issue_association_associate",
|
|
9
|
+
description: "Associate a canonical IssueRef with a team, plan-node, or epic. Accepts issueRefId or raw issue coordinates and stores only the internal IssueRef id.",
|
|
10
|
+
requiredScopes: ["issue:write"],
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
issueRefId: { type: "string" },
|
|
15
|
+
ref: { type: "string" },
|
|
16
|
+
defaultRepo: { type: "string" },
|
|
17
|
+
provider: { type: "string", enum: ISSUE_PROVIDERS },
|
|
18
|
+
repo: { type: "string" },
|
|
19
|
+
externalKey: { type: "string" },
|
|
20
|
+
url: { type: "string" },
|
|
21
|
+
title: { type: "string" },
|
|
22
|
+
entityType: { type: "string", enum: ISSUE_ASSOCIATION_ENTITY_TYPES },
|
|
23
|
+
entityId: { type: "string" },
|
|
24
|
+
},
|
|
25
|
+
required: ["entityType", "entityId"],
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "relay_issue_association_list_issues_for_entity",
|
|
31
|
+
description: "List IssueRefs associated with a team, plan-node, or epic.",
|
|
32
|
+
requiredScopes: ["issue:read"],
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
entityType: { type: "string", enum: ISSUE_ASSOCIATION_ENTITY_TYPES },
|
|
37
|
+
entityId: { type: "string" },
|
|
38
|
+
},
|
|
39
|
+
required: ["entityType", "entityId"],
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "relay_issue_association_list_entities_for_issue",
|
|
45
|
+
description: "List teams, plan nodes, and epics associated with an IssueRef. Accepts issueRefId or raw issue coordinates.",
|
|
46
|
+
requiredScopes: ["issue:read"],
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
issueRefId: { type: "string" },
|
|
51
|
+
ref: { type: "string" },
|
|
52
|
+
defaultRepo: { type: "string" },
|
|
53
|
+
provider: { type: "string", enum: ISSUE_PROVIDERS },
|
|
54
|
+
repo: { type: "string" },
|
|
55
|
+
externalKey: { type: "string" },
|
|
56
|
+
url: { type: "string" },
|
|
57
|
+
title: { type: "string" },
|
|
58
|
+
},
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "relay_issue_association_remove",
|
|
64
|
+
description: "Remove one issue/entity association. Accepts issueRefId or raw issue coordinates.",
|
|
65
|
+
requiredScopes: ["issue:write"],
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
issueRefId: { type: "string" },
|
|
70
|
+
ref: { type: "string" },
|
|
71
|
+
defaultRepo: { type: "string" },
|
|
72
|
+
provider: { type: "string", enum: ISSUE_PROVIDERS },
|
|
73
|
+
repo: { type: "string" },
|
|
74
|
+
externalKey: { type: "string" },
|
|
75
|
+
url: { type: "string" },
|
|
76
|
+
title: { type: "string" },
|
|
77
|
+
entityType: { type: "string", enum: ISSUE_ASSOCIATION_ENTITY_TYPES },
|
|
78
|
+
entityId: { type: "string" },
|
|
79
|
+
},
|
|
80
|
+
required: ["entityType", "entityId"],
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
|
|
85
|
+
|
|
86
|
+
type IssueAssociationToolAuth = { kind: string; actor: string; scopes?: string[] };
|
|
87
|
+
|
|
88
|
+
function entityTypeArg(value: unknown): IssueAssociationEntityType {
|
|
89
|
+
return cleanEnum(value, "entityType", ISSUE_ASSOCIATION_ENTITY_TYPES) as IssueAssociationEntityType;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function issueInput(args: Record<string, unknown>) {
|
|
93
|
+
return {
|
|
94
|
+
issueRefId: cleanString(args.issueRefId, "issueRefId", { max: 120 }),
|
|
95
|
+
ref: cleanString(args.ref, "ref", { max: 1000 }),
|
|
96
|
+
defaultRepo: cleanString(args.defaultRepo, "defaultRepo", { max: 500 }),
|
|
97
|
+
provider: optionalEnum(args.provider, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined,
|
|
98
|
+
repo: cleanString(args.repo, "repo", { max: 500 }),
|
|
99
|
+
externalKey: cleanString(args.externalKey, "externalKey", { max: 240 }),
|
|
100
|
+
url: cleanString(args.url, "url", { max: 2000 }),
|
|
101
|
+
title: cleanString(args.title, "title", { max: 500 }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function associationInput(args: Record<string, unknown>) {
|
|
106
|
+
return {
|
|
107
|
+
...issueInput(args),
|
|
108
|
+
entityType: entityTypeArg(args.entityType),
|
|
109
|
+
entityId: cleanString(args.entityId, "entityId", { required: true, max: 240 })!,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function relayIssueAssociationTool(_auth: IssueAssociationToolAuth, name: string, args: Record<string, unknown>): Record<string, unknown> {
|
|
114
|
+
if (!isRecord(args)) throw new ValidationError("arguments must be an object");
|
|
115
|
+
if (name === "relay_issue_association_associate") return associateIssueRef(associationInput(args)) as unknown as Record<string, unknown>;
|
|
116
|
+
if (name === "relay_issue_association_list_issues_for_entity") {
|
|
117
|
+
return {
|
|
118
|
+
issues: listIssueRefsForEntity(
|
|
119
|
+
entityTypeArg(args.entityType),
|
|
120
|
+
cleanString(args.entityId, "entityId", { required: true, max: 240 })!,
|
|
121
|
+
),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (name === "relay_issue_association_list_entities_for_issue") {
|
|
125
|
+
const issueRef = resolveAssociationIssueRef(issueInput(args));
|
|
126
|
+
return { issueRef, entities: listIssueEntities(issueRef.id) };
|
|
127
|
+
}
|
|
128
|
+
if (name === "relay_issue_association_remove") return removeIssueAssociation(associationInput(args));
|
|
129
|
+
throw new ValidationError(`unknown tool: ${name}`);
|
|
130
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { reopenIssueLifecycle } from "./services/issue-lifecycle";
|
|
2
|
+
import { cleanEnum, cleanString, optionalEnum } from "./validation";
|
|
3
|
+
import { ISSUE_ASSOCIATION_ENTITY_TYPES, ISSUE_PROVIDERS, isRecord, type IssueAssociationEntityType, type IssueProvider } from "./types";
|
|
4
|
+
import { ValidationError } from "./db";
|
|
5
|
+
|
|
6
|
+
export const ISSUE_LIFECYCLE_TOOLS = [
|
|
7
|
+
{
|
|
8
|
+
name: "relay_issue_lifecycle_reopen",
|
|
9
|
+
description: "Reopen a canonical IssueRef through its tracker adapter, update cached status to open, and optionally re-associate it with a team, plan-node, or epic.",
|
|
10
|
+
requiredScopes: ["issue:write"],
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
issueRefId: { type: "string" },
|
|
15
|
+
ref: { type: "string" },
|
|
16
|
+
defaultRepo: { type: "string" },
|
|
17
|
+
provider: { type: "string", enum: ISSUE_PROVIDERS },
|
|
18
|
+
repo: { type: "string" },
|
|
19
|
+
externalKey: { type: "string" },
|
|
20
|
+
url: { type: "string" },
|
|
21
|
+
title: { type: "string" },
|
|
22
|
+
comment: { type: "string" },
|
|
23
|
+
agentId: { type: "string" },
|
|
24
|
+
entityType: { type: "string", enum: ISSUE_ASSOCIATION_ENTITY_TYPES },
|
|
25
|
+
entityId: { type: "string" },
|
|
26
|
+
},
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
|
|
31
|
+
|
|
32
|
+
type IssueLifecycleToolAuth = { kind: string; actor: string; scopes?: string[] };
|
|
33
|
+
|
|
34
|
+
function reopenInput(args: Record<string, unknown>) {
|
|
35
|
+
return {
|
|
36
|
+
issueRefId: cleanString(args.issueRefId, "issueRefId", { max: 120 }),
|
|
37
|
+
ref: cleanString(args.ref, "ref", { max: 1000 }),
|
|
38
|
+
defaultRepo: cleanString(args.defaultRepo ?? args.repo, "defaultRepo", { max: 500 }),
|
|
39
|
+
provider: optionalEnum(args.provider, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined,
|
|
40
|
+
repo: cleanString(args.repo, "repo", { max: 500 }),
|
|
41
|
+
externalKey: cleanString(args.externalKey, "externalKey", { max: 240 }),
|
|
42
|
+
url: cleanString(args.url, "url", { max: 2000 }),
|
|
43
|
+
title: cleanString(args.title, "title", { max: 500 }),
|
|
44
|
+
comment: cleanString(args.comment, "comment", { max: 2000 }),
|
|
45
|
+
agentId: cleanString(args.agentId, "agentId", { max: 200 }),
|
|
46
|
+
entityType: args.entityType === undefined ? undefined : cleanEnum(args.entityType, "entityType", ISSUE_ASSOCIATION_ENTITY_TYPES) as IssueAssociationEntityType,
|
|
47
|
+
entityId: cleanString(args.entityId, "entityId", { max: 240 }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function relayIssueLifecycleTool(_auth: IssueLifecycleToolAuth, name: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
52
|
+
if (!isRecord(args)) throw new ValidationError("arguments must be an object");
|
|
53
|
+
if (name === "relay_issue_lifecycle_reopen") return await reopenIssueLifecycle(reopenInput(args)) as unknown as Record<string, unknown>;
|
|
54
|
+
throw new ValidationError(`unknown tool: ${name}`);
|
|
55
|
+
}
|