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
package/public/sw.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const CACHE_NAME = "agent-relay-dashboard-
|
|
1
|
+
const CACHE_NAME = "agent-relay-dashboard-v4";
|
|
2
2
|
const scopeUrl = new URL(self.registration.scope);
|
|
3
3
|
const scopePath = scopeUrl.pathname.endsWith("/") ? scopeUrl.pathname : `${scopeUrl.pathname}/`;
|
|
4
4
|
const appUrl = (path) => new URL(path, self.registration.scope).toString();
|
|
@@ -65,14 +65,25 @@ self.addEventListener("fetch", (event) => {
|
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
68
|
+
// index.html / SPA navigations: NETWORK-FIRST. The shell embeds the hashed
|
|
69
|
+
// code-split chunk filenames, which change on every deploy — and the build
|
|
70
|
+
// deletes the old chunk files. Serving a stale cached shell (the old
|
|
71
|
+
// stale-while-revalidate behavior) therefore hands the app a chunk map whose
|
|
72
|
+
// files are already gone → "Failed to fetch dynamically imported module" on
|
|
73
|
+
// the first navigation after a deploy. Always fetch a fresh shell when online
|
|
74
|
+
// so its chunk refs match the live /assets; fall back to cache only offline.
|
|
75
|
+
// Keyed to index.html (not the deep-link path) to keep one shell entry.
|
|
73
76
|
const isNavigation = request.mode === "navigate";
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
const indexHref = appUrl("index.html");
|
|
78
|
+
if (isNavigation || url.href === indexHref) {
|
|
79
|
+
event.respondWith(networkFirst(request, indexHref));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Other app-shell entries (manifest, icons) aren't chunk-coupled, so stale is
|
|
84
|
+
// harmless: serve cached instantly and refresh in the background.
|
|
85
|
+
if (APP_SHELL.includes(url.href)) {
|
|
86
|
+
event.respondWith(staleWhileRevalidate(request, request));
|
|
76
87
|
return;
|
|
77
88
|
}
|
|
78
89
|
|
|
@@ -95,6 +106,19 @@ self.addEventListener("fetch", (event) => {
|
|
|
95
106
|
);
|
|
96
107
|
});
|
|
97
108
|
|
|
109
|
+
async function networkFirst(request, cacheKey) {
|
|
110
|
+
const cache = await caches.open(CACHE_NAME);
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetch(request);
|
|
113
|
+
if (response.ok) await cacheNormalized(cache, cacheKey, response);
|
|
114
|
+
return response;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
const cached = await cache.match(cacheKey);
|
|
117
|
+
if (cached) return cached;
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
98
122
|
async function cacheFirst(request) {
|
|
99
123
|
const cache = await caches.open(CACHE_NAME);
|
|
100
124
|
const cached = await cache.match(request);
|
package/src/automations.ts
CHANGED
|
@@ -39,8 +39,7 @@ import type {
|
|
|
39
39
|
UpdateAutomationInput,
|
|
40
40
|
} from "./types";
|
|
41
41
|
const AUTOMATION_KIND = "scheduled_task";
|
|
42
|
-
const DEFAULT_TIMEZONE = "UTC";
|
|
43
|
-
const DEFAULT_CATCH_UP: AutomationCatchUpPolicy = "skip";
|
|
42
|
+
const DEFAULT_TIMEZONE = "UTC", DEFAULT_CATCH_UP: AutomationCatchUpPolicy = "skip";
|
|
44
43
|
const DEFAULT_CONCURRENCY: AutomationConcurrencyPolicy = "skip";
|
|
45
44
|
const BLOCKING_RUN_STATUSES = new Set<AutomationRunStatus>(["dispatching", "waiting_agent", "running"]);
|
|
46
45
|
const OPEN_RUN_STATUSES = new Set<AutomationRunStatus>(["scheduled", "dispatching", "waiting_agent", "running"]);
|
|
@@ -50,7 +49,6 @@ const MIN_RUNTIME_BUDGET_MS = 60_000;
|
|
|
50
49
|
const MAX_RUNTIME_BUDGET_MS = 24 * 60 * 60 * 1000;
|
|
51
50
|
|
|
52
51
|
let initializedDb: unknown = null;
|
|
53
|
-
|
|
54
52
|
export interface AutomationDispatchResult {
|
|
55
53
|
automation: Automation;
|
|
56
54
|
run: AutomationRun;
|
|
@@ -65,11 +63,9 @@ export interface AutomationReconcileResult {
|
|
|
65
63
|
message?: Message;
|
|
66
64
|
command?: Command;
|
|
67
65
|
}
|
|
68
|
-
|
|
69
66
|
function db() {
|
|
70
67
|
return getDb();
|
|
71
68
|
}
|
|
72
|
-
|
|
73
69
|
function ensureAutomationTables(): void {
|
|
74
70
|
const current = db();
|
|
75
71
|
if (initializedDb === current) return;
|
|
@@ -563,14 +559,31 @@ function createRunTask(
|
|
|
563
559
|
meta: Record<string, unknown>,
|
|
564
560
|
): AutomationDispatchResult {
|
|
565
561
|
const template = automation.taskTemplate;
|
|
562
|
+
// Inject the authoritative dispatch-time run date (#480). A long-lived managed agent's
|
|
563
|
+
// session-start day awareness goes stale across midnight, so it can't be trusted to derive
|
|
564
|
+
// "today" — the briefing emitted "Monday Jun 16" when it ran on a Tuesday. The scheduled
|
|
565
|
+
// instant + timezone are authoritative here; surface them so the task never depends on the
|
|
566
|
+
// model's recollection. Templates may place it via {{runDate}}/{{runWeekday}}/{{runDateLine}};
|
|
567
|
+
// otherwise we prepend a Run date line so every scheduled task carries it.
|
|
568
|
+
const runDate = formatRunDate(run.scheduledFor, automation.timezone);
|
|
569
|
+
const tokens: Record<string, string> = { runDate: runDate.iso, runWeekday: runDate.weekday, runDateLine: runDate.line };
|
|
570
|
+
const fill = (text: string) => text.replace(/\{\{\s*(runDate|runWeekday|runDateLine)\s*\}\}/g, (_match, key) => tokens[key] ?? "");
|
|
571
|
+
const usesRunDateToken = /\{\{\s*(runDate|runWeekday|runDateLine)\s*\}\}/.test(`${template.title}\n${template.body}`);
|
|
572
|
+
const title = fill(template.title);
|
|
573
|
+
const body = usesRunDateToken ? fill(template.body) : `Run date: ${runDate.line}.\n\n${template.body}`;
|
|
566
574
|
const result = ingestIntegrationEvent({
|
|
567
575
|
source: "automation",
|
|
568
576
|
type: "scheduled_task",
|
|
569
577
|
severity: template.severity ?? "info",
|
|
570
|
-
title
|
|
571
|
-
body
|
|
578
|
+
title,
|
|
579
|
+
body,
|
|
572
580
|
target,
|
|
573
581
|
channel: "automation",
|
|
582
|
+
// #479 — automation tasks complete via the task-status lifecycle (claim → done), never a
|
|
583
|
+
// `/reply`. A reply obligation here is unsatisfiable and re-injects the task into every
|
|
584
|
+
// successor session when a restart-prone managed agent (e.g. the daily telegram briefing)
|
|
585
|
+
// mints a new agent id, re-firing the scheduled work off-schedule. Dispatch without one.
|
|
586
|
+
replyExpected: false,
|
|
574
587
|
dedupeKey: template.dedupeKey ? `${template.dedupeKey}:${run.id}` : `automation:${automation.id}:run:${run.id}`,
|
|
575
588
|
externalUrl: template.externalUrl,
|
|
576
589
|
metadata: {
|
|
@@ -578,6 +591,9 @@ function createRunTask(
|
|
|
578
591
|
automationId: automation.id,
|
|
579
592
|
automationRunId: run.id,
|
|
580
593
|
orchestratorId: automation.orchestratorId,
|
|
594
|
+
runDate: runDate.iso,
|
|
595
|
+
runWeekday: runDate.weekday,
|
|
596
|
+
runTimezone: automation.timezone,
|
|
581
597
|
...meta,
|
|
582
598
|
},
|
|
583
599
|
}, "automation");
|
|
@@ -979,6 +995,23 @@ function parseCronField(field: string, min: number, max: number, name: string):
|
|
|
979
995
|
return values;
|
|
980
996
|
}
|
|
981
997
|
|
|
998
|
+
// Authoritative localized run date for a dispatched automation task (#480). Formats the
|
|
999
|
+
// scheduled instant in the automation's timezone via the same Intl machinery as cron eval, so
|
|
1000
|
+
// scheduled output ("today is …") never depends on a long-lived agent's stale session context.
|
|
1001
|
+
function formatRunDate(timestamp: number, timezone: string): { iso: string; weekday: string; line: string } {
|
|
1002
|
+
const dtf = new Intl.DateTimeFormat("en-US", {
|
|
1003
|
+
timeZone: timezone,
|
|
1004
|
+
year: "numeric",
|
|
1005
|
+
month: "2-digit",
|
|
1006
|
+
day: "2-digit",
|
|
1007
|
+
weekday: "long",
|
|
1008
|
+
});
|
|
1009
|
+
const parts = Object.fromEntries(dtf.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]));
|
|
1010
|
+
const iso = `${parts.year}-${parts.month}-${parts.day}`;
|
|
1011
|
+
const weekday = parts.weekday ?? "";
|
|
1012
|
+
return { iso, weekday, line: `${weekday}, ${iso} (${timezone})` };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
982
1015
|
function localParts(timestamp: number, timezone: string): LocalTimeParts {
|
|
983
1016
|
const dtf = new Intl.DateTimeFormat("en-US", {
|
|
984
1017
|
timeZone: timezone,
|
package/src/branch-landed.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { errMessage } from "agent-relay-sdk";
|
|
1
2
|
import { emitRelayEvent } from "./events";
|
|
2
3
|
import { getNotificationsConfig } from "./config-store";
|
|
3
4
|
import { routeNotification, INFORMATIONAL_TTL_MS } from "./notification-router";
|
|
4
5
|
import { registerDefaultAudienceRules, resolveAudience, audiencePredicates } from "./notification-rules";
|
|
5
6
|
import { createActivityEvent, getAgent, updateWorkspaceStatus } from "./db";
|
|
7
|
+
import { handleLandedIssueLifecycle } from "./services/issue-lifecycle";
|
|
6
8
|
import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
|
|
7
9
|
|
|
8
10
|
// #444 — branch.landed is the first rule-driven type: the on-main "merged" fan-out audience comes
|
|
@@ -64,6 +66,9 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
|
64
66
|
pushed: input.pushed,
|
|
65
67
|
},
|
|
66
68
|
});
|
|
69
|
+
void handleLandedIssueLifecycle(input, { log: (message) => console.warn(message) }).catch((error) => {
|
|
70
|
+
console.warn(`issue lifecycle land hook failed for ${workspace.id}: ${errMessage(error)}`);
|
|
71
|
+
});
|
|
67
72
|
|
|
68
73
|
const config = getNotificationsConfig();
|
|
69
74
|
if (!config.enabled || !config.branchLanded) return;
|
package/src/bus.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFro
|
|
|
14
14
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
15
15
|
import { enqueueContinuationInjectionAfterClear } from "./self-resume";
|
|
16
16
|
import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
|
|
17
|
+
import { probeWorkspaceOnTurnEnd } from "./workspace-probe";
|
|
17
18
|
import {
|
|
18
19
|
BusProtocolError,
|
|
19
20
|
parseBusFrame,
|
|
@@ -174,6 +175,8 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
174
175
|
// merged meta — clears any pending stall watch (stale events ignored).
|
|
175
176
|
noteAgentTimelineEvent(conn.agentId, after?.meta?.timelineEvent);
|
|
176
177
|
emitAgentStatusEvent(conn.agentId);
|
|
178
|
+
// #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
|
|
179
|
+
probeWorkspaceOnTurnEnd(before, after);
|
|
177
180
|
});
|
|
178
181
|
return;
|
|
179
182
|
case "ack":
|
package/src/cli/workspace.ts
CHANGED
|
@@ -5,8 +5,7 @@ import { apiRequest } from "./_shared";
|
|
|
5
5
|
import { detectAgentId } from "./agent-detect";
|
|
6
6
|
import { describeWorkspacePhase, readyContract, type WorkspacePhaseView } from "../workspace-phase";
|
|
7
7
|
import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
|
|
8
|
-
import { AUTO_MERGE_POLICIES
|
|
9
|
-
import { getLandingConfig } from "../config-store";
|
|
8
|
+
import { AUTO_MERGE_POLICIES } from "../workspace-merge";
|
|
10
9
|
import type { WorkspaceAutoMergePolicy } from "../types";
|
|
11
10
|
|
|
12
11
|
export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--auto-merge on-green|on-approval|manual] [--reviewer HANDLE] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]";
|
|
@@ -204,19 +203,12 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
204
203
|
|
|
205
204
|
const from = await detectAgentId();
|
|
206
205
|
|
|
207
|
-
// For the `land` action,
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const instanceConfig = getLandingConfig();
|
|
214
|
-
resolvedStrategy = resolveLandStrategy({
|
|
215
|
-
explicit: strategy,
|
|
216
|
-
instanceStrategy: instanceConfig.strategy,
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
|
|
206
|
+
// For the `land` action, pass --strategy through verbatim (undefined when unset).
|
|
207
|
+
// The server is the SINGLE resolution point: requestWorkspaceMerge resolves the
|
|
208
|
+
// three-level chain (explicit > instance config > default) server-side. The CLI
|
|
209
|
+
// must NOT read landing config locally — that needs the server DB, which isn't
|
|
210
|
+
// initialized in a CLI/steward process and fails with "database not initialized"
|
|
211
|
+
// (#481). resolveLandStrategy + getLandingConfig stay server-only.
|
|
220
212
|
const actionBody: Record<string, unknown> =
|
|
221
213
|
action === "ready" ? { action: "request-review", agentId: from }
|
|
222
214
|
: action === "claim" ? { action: "claim", agentId: from, purpose }
|
|
@@ -224,7 +216,7 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
224
216
|
: {
|
|
225
217
|
action: "merge",
|
|
226
218
|
agentId: from,
|
|
227
|
-
...(
|
|
219
|
+
...(strategy ? { strategy } : {}),
|
|
228
220
|
...(autoMerge ? { autoMerge } : {}),
|
|
229
221
|
...(reviewer ? { reviewer } : {}),
|
|
230
222
|
};
|
|
@@ -243,6 +235,6 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
243
235
|
console.log(
|
|
244
236
|
action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
|
|
245
237
|
: action === "release" ? `Workspace ${id} claim released.`
|
|
246
|
-
: `Workspace ${id} merge requested (${
|
|
238
|
+
: `Workspace ${id} merge requested (${strategy ?? "server default"}${autoMerge ? `, autoMerge: ${autoMerge}` : ""}${reviewer ? `, reviewer: ${reviewer}` : ""}).`,
|
|
247
239
|
);
|
|
248
240
|
}
|
package/src/db/activity.ts
CHANGED
|
@@ -14,8 +14,9 @@ import {
|
|
|
14
14
|
} from "../contracts";
|
|
15
15
|
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
|
-
import { getDb } from "./connection.ts";
|
|
17
|
+
import { getDb, ValidationError } from "./connection.ts";
|
|
18
18
|
import { rowToActivityEvent } from "./mappers.ts";
|
|
19
|
+
import { getIssueRef, resolveIssueRefFromString } from "./issue-refs.ts";
|
|
19
20
|
import type {
|
|
20
21
|
AgentCard,
|
|
21
22
|
ActivityEvent,
|
|
@@ -77,11 +78,49 @@ import type {
|
|
|
77
78
|
WorkspaceMetadata,
|
|
78
79
|
WorkspaceRecord,
|
|
79
80
|
WorkspaceStatus,
|
|
81
|
+
IssueProvider,
|
|
82
|
+
IssueRef,
|
|
80
83
|
} from "../types";
|
|
81
84
|
|
|
85
|
+
export const ISSUE_ACTIVITY_ACTIONS = ["read", "edit", "done"] as const;
|
|
86
|
+
export type IssueActivityAction = (typeof ISSUE_ACTIVITY_ACTIONS)[number];
|
|
87
|
+
|
|
88
|
+
export interface RecordIssueActivityInput {
|
|
89
|
+
agentId: string;
|
|
90
|
+
action: IssueActivityAction;
|
|
91
|
+
issueRefId?: string;
|
|
92
|
+
issueRef?: string;
|
|
93
|
+
defaultRepo?: string;
|
|
94
|
+
provider?: IssueProvider;
|
|
95
|
+
clientId?: string;
|
|
96
|
+
title?: string;
|
|
97
|
+
body?: string;
|
|
98
|
+
metadata?: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface IssueActivityAgentSummary {
|
|
102
|
+
agentId: string;
|
|
103
|
+
agentName?: string;
|
|
104
|
+
agentKind?: string;
|
|
105
|
+
action: IssueActivityAction;
|
|
106
|
+
count: number;
|
|
107
|
+
firstAt: number;
|
|
108
|
+
lastAt: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface AgentIssueActivitySummary {
|
|
112
|
+
issueRef: IssueRef;
|
|
113
|
+
action: IssueActivityAction;
|
|
114
|
+
count: number;
|
|
115
|
+
firstAt: number;
|
|
116
|
+
lastAt: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
82
119
|
export function listActivityEvents(input: {
|
|
83
120
|
operatorId?: string;
|
|
84
121
|
agentId?: string;
|
|
122
|
+
issueRefId?: string;
|
|
123
|
+
issueAction?: IssueActivityAction;
|
|
85
124
|
limit?: number;
|
|
86
125
|
since?: number;
|
|
87
126
|
} = {}): ActivityEvent[] {
|
|
@@ -95,6 +134,14 @@ export function listActivityEvents(input: {
|
|
|
95
134
|
conditions.push("agent_id = ?");
|
|
96
135
|
params.push(input.agentId);
|
|
97
136
|
}
|
|
137
|
+
if (input.issueRefId) {
|
|
138
|
+
conditions.push("issue_ref_id = ?");
|
|
139
|
+
params.push(input.issueRefId);
|
|
140
|
+
}
|
|
141
|
+
if (input.issueAction) {
|
|
142
|
+
conditions.push("issue_action = ?");
|
|
143
|
+
params.push(input.issueAction);
|
|
144
|
+
}
|
|
98
145
|
if (input.since !== undefined) {
|
|
99
146
|
conditions.push("created_at >= ?");
|
|
100
147
|
params.push(input.since);
|
|
@@ -169,9 +216,9 @@ export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
|
|
|
169
216
|
const result = getDb().query(`
|
|
170
217
|
INSERT INTO activity_events (
|
|
171
218
|
operator_id, client_id, kind, title, body, meta_text, icon, view, peer_id,
|
|
172
|
-
message_id, pair_id, task_id, agent_id, metadata, created_at
|
|
219
|
+
message_id, pair_id, task_id, agent_id, issue_ref_id, issue_action, metadata, created_at
|
|
173
220
|
)
|
|
174
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
221
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
175
222
|
`).run(
|
|
176
223
|
input.operatorId ?? null,
|
|
177
224
|
input.clientId ?? null,
|
|
@@ -186,9 +233,150 @@ export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
|
|
|
186
233
|
input.pairId ?? null,
|
|
187
234
|
input.taskId ?? null,
|
|
188
235
|
input.agentId ?? null,
|
|
236
|
+
input.issueRefId ?? null,
|
|
237
|
+
input.issueAction ?? null,
|
|
189
238
|
JSON.stringify(input.metadata ?? {}),
|
|
190
239
|
now,
|
|
191
240
|
);
|
|
192
241
|
return rowToActivityEvent(getDb().query("SELECT * FROM activity_events WHERE id = ?").get(Number(result.lastInsertRowid)));
|
|
193
242
|
}
|
|
194
243
|
|
|
244
|
+
function requiredText(value: string | undefined, field: string, max: number): string {
|
|
245
|
+
if (typeof value !== "string") throw new ValidationError(`${field} required`);
|
|
246
|
+
const cleaned = value.trim();
|
|
247
|
+
if (!cleaned) throw new ValidationError(`${field} required`);
|
|
248
|
+
if (cleaned.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
|
|
249
|
+
return cleaned;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function optionalText(value: string | undefined, field: string, max: number): string | undefined {
|
|
253
|
+
if (value === undefined) return undefined;
|
|
254
|
+
const cleaned = requiredText(value, field, max);
|
|
255
|
+
return cleaned || undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function cleanIssueAction(action: IssueActivityAction): IssueActivityAction {
|
|
259
|
+
if (!ISSUE_ACTIVITY_ACTIONS.includes(action)) {
|
|
260
|
+
throw new ValidationError(`action must be one of: ${ISSUE_ACTIVITY_ACTIONS.join(", ")}`);
|
|
261
|
+
}
|
|
262
|
+
return action;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function cleanIssueActivityLimit(limit: number | undefined): number {
|
|
266
|
+
if (limit === undefined) return 200;
|
|
267
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 500) throw new ValidationError("limit must be between 1 and 500");
|
|
268
|
+
return limit;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function resolveIssueRefForActivity(input: { issueRefId?: string; issueRef?: string; defaultRepo?: string; provider?: IssueProvider }): IssueRef {
|
|
272
|
+
const issueRefId = optionalText(input.issueRefId, "issueRefId", 120);
|
|
273
|
+
if (issueRefId) {
|
|
274
|
+
const issueRef = getIssueRef(issueRefId);
|
|
275
|
+
if (!issueRef) throw new ValidationError(`issue ref ${issueRefId} not found`);
|
|
276
|
+
return issueRef;
|
|
277
|
+
}
|
|
278
|
+
const ref = optionalText(input.issueRef, "issueRef", 1000);
|
|
279
|
+
if (!ref) throw new ValidationError("issueRefId or issueRef required");
|
|
280
|
+
return resolveIssueRefFromString({
|
|
281
|
+
ref,
|
|
282
|
+
defaultRepo: optionalText(input.defaultRepo, "defaultRepo", 500),
|
|
283
|
+
provider: input.provider,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function resolveIssueActivityRef(input: { issueRefId?: string; issueRef?: string; defaultRepo?: string; provider?: IssueProvider }): IssueRef {
|
|
288
|
+
return resolveIssueRefForActivity(input);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function recordIssueActivity(input: RecordIssueActivityInput): ActivityEvent {
|
|
292
|
+
const agentId = requiredText(input.agentId, "agentId", 200);
|
|
293
|
+
const action = cleanIssueAction(input.action);
|
|
294
|
+
const issueRef = resolveIssueRefForActivity(input);
|
|
295
|
+
const metadata = {
|
|
296
|
+
...(input.metadata ?? {}),
|
|
297
|
+
issueRefId: issueRef.id,
|
|
298
|
+
issueAction: action,
|
|
299
|
+
};
|
|
300
|
+
return createActivityEvent({
|
|
301
|
+
clientId: optionalText(input.clientId, "clientId", 240),
|
|
302
|
+
kind: "issue",
|
|
303
|
+
title: optionalText(input.title, "title", 200) ?? `Issue ${action}`,
|
|
304
|
+
body: optionalText(input.body, "body", 1000),
|
|
305
|
+
agentId,
|
|
306
|
+
issueRefId: issueRef.id,
|
|
307
|
+
issueAction: action,
|
|
308
|
+
metadata,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function listIssueActivityAgents(issueRefId: string, opts: { limit?: number } = {}): IssueActivityAgentSummary[] {
|
|
313
|
+
const id = requiredText(issueRefId, "issueRefId", 120);
|
|
314
|
+
if (!getIssueRef(id)) throw new ValidationError(`issue ref ${id} not found`);
|
|
315
|
+
const rows = getDb().query(`
|
|
316
|
+
SELECT
|
|
317
|
+
ae.agent_id,
|
|
318
|
+
a.name AS agent_name,
|
|
319
|
+
a.kind AS agent_kind,
|
|
320
|
+
ae.issue_action,
|
|
321
|
+
count(*) AS count,
|
|
322
|
+
min(ae.created_at) AS first_at,
|
|
323
|
+
max(ae.created_at) AS last_at
|
|
324
|
+
FROM activity_events ae
|
|
325
|
+
LEFT JOIN agents a ON a.id = ae.agent_id
|
|
326
|
+
WHERE ae.issue_ref_id = ? AND ae.agent_id IS NOT NULL AND ae.issue_action IS NOT NULL
|
|
327
|
+
GROUP BY ae.agent_id, ae.issue_action
|
|
328
|
+
ORDER BY last_at DESC, ae.agent_id ASC, ae.issue_action ASC
|
|
329
|
+
LIMIT ?
|
|
330
|
+
`).all(id, cleanIssueActivityLimit(opts.limit)) as Array<{
|
|
331
|
+
agent_id: string;
|
|
332
|
+
agent_name: string | null;
|
|
333
|
+
agent_kind: string | null;
|
|
334
|
+
issue_action: IssueActivityAction;
|
|
335
|
+
count: number;
|
|
336
|
+
first_at: number;
|
|
337
|
+
last_at: number;
|
|
338
|
+
}>;
|
|
339
|
+
return rows.map((row) => ({
|
|
340
|
+
agentId: row.agent_id,
|
|
341
|
+
...(row.agent_name ? { agentName: row.agent_name } : {}),
|
|
342
|
+
...(row.agent_kind ? { agentKind: row.agent_kind } : {}),
|
|
343
|
+
action: cleanIssueAction(row.issue_action),
|
|
344
|
+
count: row.count,
|
|
345
|
+
firstAt: row.first_at,
|
|
346
|
+
lastAt: row.last_at,
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function listAgentIssueActivity(agentId: string, opts: { limit?: number } = {}): AgentIssueActivitySummary[] {
|
|
351
|
+
const id = requiredText(agentId, "agentId", 200);
|
|
352
|
+
const rows = getDb().query(`
|
|
353
|
+
SELECT
|
|
354
|
+
ae.issue_ref_id,
|
|
355
|
+
ae.issue_action,
|
|
356
|
+
count(*) AS count,
|
|
357
|
+
min(ae.created_at) AS first_at,
|
|
358
|
+
max(ae.created_at) AS last_at
|
|
359
|
+
FROM activity_events ae
|
|
360
|
+
WHERE ae.agent_id = ? AND ae.issue_ref_id IS NOT NULL AND ae.issue_action IS NOT NULL
|
|
361
|
+
GROUP BY ae.issue_ref_id, ae.issue_action
|
|
362
|
+
ORDER BY last_at DESC, ae.issue_ref_id ASC, ae.issue_action ASC
|
|
363
|
+
LIMIT ?
|
|
364
|
+
`).all(id, cleanIssueActivityLimit(opts.limit)) as Array<{
|
|
365
|
+
issue_ref_id: string;
|
|
366
|
+
issue_action: IssueActivityAction;
|
|
367
|
+
count: number;
|
|
368
|
+
first_at: number;
|
|
369
|
+
last_at: number;
|
|
370
|
+
}>;
|
|
371
|
+
return rows.flatMap((row) => {
|
|
372
|
+
const issueRef = getIssueRef(row.issue_ref_id);
|
|
373
|
+
if (!issueRef) return [];
|
|
374
|
+
return [{
|
|
375
|
+
issueRef,
|
|
376
|
+
action: cleanIssueAction(row.issue_action),
|
|
377
|
+
count: row.count,
|
|
378
|
+
firstAt: row.first_at,
|
|
379
|
+
lastAt: row.last_at,
|
|
380
|
+
}];
|
|
381
|
+
});
|
|
382
|
+
}
|
package/src/db/integrations.ts
CHANGED
|
@@ -166,6 +166,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
166
166
|
subject: `[${task.severity}] ${task.title}`,
|
|
167
167
|
body: taskMessageBody(task),
|
|
168
168
|
claimable: true,
|
|
169
|
+
replyExpected: input.replyExpected,
|
|
169
170
|
payload: {
|
|
170
171
|
taskId: task.id,
|
|
171
172
|
source: task.source,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { ISSUE_ASSOCIATION_ENTITY_TYPES, type IssueAssociation, type IssueAssociationEntity, type IssueAssociationEntityType, type IssueAssociationIssue } from "../types";
|
|
2
|
+
import { getDb, ValidationError } from "./connection.ts";
|
|
3
|
+
import { getIssueRef } from "./issue-refs.ts";
|
|
4
|
+
|
|
5
|
+
interface IssueAssociationRow {
|
|
6
|
+
issue_ref_id: string;
|
|
7
|
+
entity_type: IssueAssociationEntityType;
|
|
8
|
+
entity_id: string;
|
|
9
|
+
created_at: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function planNodeIssueEntityId(planId: string, nodeId: string): string {
|
|
13
|
+
return `${cleanText(planId, "planId", 120)}:${cleanText(nodeId, "nodeId", 120)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rowToAssociation(row: IssueAssociationRow): IssueAssociation {
|
|
17
|
+
return {
|
|
18
|
+
issueRefId: row.issue_ref_id,
|
|
19
|
+
entityType: row.entity_type,
|
|
20
|
+
entityId: row.entity_id,
|
|
21
|
+
createdAt: row.created_at,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cleanEntityType(value: IssueAssociationEntityType): IssueAssociationEntityType {
|
|
26
|
+
if (!ISSUE_ASSOCIATION_ENTITY_TYPES.includes(value)) {
|
|
27
|
+
throw new ValidationError(`entityType must be one of: ${ISSUE_ASSOCIATION_ENTITY_TYPES.join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cleanText(value: string | undefined, field: string, max: number): string {
|
|
33
|
+
if (typeof value !== "string") throw new ValidationError(`${field} required`);
|
|
34
|
+
const cleaned = value.trim();
|
|
35
|
+
if (!cleaned) throw new ValidationError(`${field} required`);
|
|
36
|
+
if (cleaned.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
|
|
37
|
+
return cleaned;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function associateIssue(issueRefId: string, entityType: IssueAssociationEntityType, entityId: string): IssueAssociation {
|
|
41
|
+
const cleanedIssueRefId = cleanText(issueRefId, "issueRefId", 120);
|
|
42
|
+
if (!getIssueRef(cleanedIssueRefId)) throw new ValidationError(`issue ref ${cleanedIssueRefId} not found`);
|
|
43
|
+
const cleanedEntityType = cleanEntityType(entityType);
|
|
44
|
+
const cleanedEntityId = cleanText(entityId, "entityId", 240);
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
getDb().query(`
|
|
47
|
+
INSERT INTO issue_associations (issue_ref_id, entity_type, entity_id, created_at)
|
|
48
|
+
VALUES (?, ?, ?, ?)
|
|
49
|
+
ON CONFLICT(issue_ref_id, entity_type, entity_id) DO NOTHING
|
|
50
|
+
`).run(cleanedIssueRefId, cleanedEntityType, cleanedEntityId, now);
|
|
51
|
+
const row = getDb().query(`
|
|
52
|
+
SELECT issue_ref_id, entity_type, entity_id, created_at
|
|
53
|
+
FROM issue_associations
|
|
54
|
+
WHERE issue_ref_id = ? AND entity_type = ? AND entity_id = ?
|
|
55
|
+
`).get(cleanedIssueRefId, cleanedEntityType, cleanedEntityId) as IssueAssociationRow | undefined;
|
|
56
|
+
if (!row) throw new ValidationError("issue association could not be created");
|
|
57
|
+
return rowToAssociation(row);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function listIssuesForEntity(entityType: IssueAssociationEntityType, entityId: string): IssueAssociationIssue[] {
|
|
61
|
+
const rows = getDb().query(`
|
|
62
|
+
SELECT
|
|
63
|
+
a.issue_ref_id,
|
|
64
|
+
a.entity_type,
|
|
65
|
+
a.entity_id,
|
|
66
|
+
a.created_at,
|
|
67
|
+
r.id,
|
|
68
|
+
r.provider,
|
|
69
|
+
r.repo,
|
|
70
|
+
r.external_key,
|
|
71
|
+
r.url,
|
|
72
|
+
r.title,
|
|
73
|
+
r.status,
|
|
74
|
+
r.status_synced_at,
|
|
75
|
+
r.created_at AS issue_created_at,
|
|
76
|
+
r.updated_at AS issue_updated_at
|
|
77
|
+
FROM issue_associations a
|
|
78
|
+
JOIN issue_refs r ON r.id = a.issue_ref_id
|
|
79
|
+
WHERE a.entity_type = ? AND a.entity_id = ?
|
|
80
|
+
ORDER BY a.created_at DESC, a.issue_ref_id DESC
|
|
81
|
+
`).all(cleanEntityType(entityType), cleanText(entityId, "entityId", 240)) as Array<IssueAssociationRow & {
|
|
82
|
+
id: string;
|
|
83
|
+
provider: IssueAssociationIssue["issueRef"]["provider"];
|
|
84
|
+
repo: string;
|
|
85
|
+
external_key: string;
|
|
86
|
+
url: string;
|
|
87
|
+
title: string | null;
|
|
88
|
+
status: IssueAssociationIssue["issueRef"]["status"];
|
|
89
|
+
status_synced_at: number | null;
|
|
90
|
+
issue_created_at: number;
|
|
91
|
+
issue_updated_at: number;
|
|
92
|
+
}>;
|
|
93
|
+
return rows.map((row) => ({
|
|
94
|
+
association: rowToAssociation(row),
|
|
95
|
+
issueRef: {
|
|
96
|
+
id: row.id,
|
|
97
|
+
provider: row.provider,
|
|
98
|
+
repo: row.repo,
|
|
99
|
+
externalKey: row.external_key,
|
|
100
|
+
url: row.url,
|
|
101
|
+
...(row.title ? { title: row.title } : {}),
|
|
102
|
+
status: row.status,
|
|
103
|
+
...(row.status_synced_at !== null ? { statusSyncedAt: row.status_synced_at } : {}),
|
|
104
|
+
createdAt: row.issue_created_at,
|
|
105
|
+
updatedAt: row.issue_updated_at,
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function listEntitiesForIssue(issueRefId: string): IssueAssociationEntity[] {
|
|
111
|
+
const cleanedIssueRefId = cleanText(issueRefId, "issueRefId", 120);
|
|
112
|
+
if (!getIssueRef(cleanedIssueRefId)) throw new ValidationError(`issue ref ${cleanedIssueRefId} not found`);
|
|
113
|
+
const rows = getDb().query(`
|
|
114
|
+
SELECT issue_ref_id, entity_type, entity_id, created_at
|
|
115
|
+
FROM issue_associations
|
|
116
|
+
WHERE issue_ref_id = ?
|
|
117
|
+
ORDER BY created_at DESC, entity_type ASC, entity_id ASC
|
|
118
|
+
`).all(cleanedIssueRefId) as IssueAssociationRow[];
|
|
119
|
+
return rows.map((row) => ({ entityType: row.entity_type, entityId: row.entity_id, createdAt: row.created_at }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function removeAssociation(issueRefId: string, entityType: IssueAssociationEntityType, entityId: string): boolean {
|
|
123
|
+
const result = getDb().query(`
|
|
124
|
+
DELETE FROM issue_associations
|
|
125
|
+
WHERE issue_ref_id = ? AND entity_type = ? AND entity_id = ?
|
|
126
|
+
`).run(cleanText(issueRefId, "issueRefId", 120), cleanEntityType(entityType), cleanText(entityId, "entityId", 240));
|
|
127
|
+
return result.changes > 0;
|
|
128
|
+
}
|