agent-relay-server 0.110.0 → 0.111.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-CmqJMGYF.js → MessageBubble-WYqSyKPM.js} +2 -2
- package/public/assets/{MessageBubble-CmqJMGYF.js.map → MessageBubble-WYqSyKPM.js.map} +1 -1
- package/public/assets/PlanGraphPanel-Dvu17jGe.js +2 -0
- package/public/assets/PlanGraphPanel-Dvu17jGe.js.map +1 -0
- package/public/assets/{activity-DyqKrJe_.js → activity-wAN_upn7.js} +2 -2
- package/public/assets/{activity-DyqKrJe_.js.map → activity-wAN_upn7.js.map} +1 -1
- package/public/assets/{agent-profiles-DRQ5vbPo.js → agent-profiles-D8WLdTCs.js} +2 -2
- package/public/assets/{agent-profiles-DRQ5vbPo.js.map → agent-profiles-D8WLdTCs.js.map} +1 -1
- package/public/assets/{agents-BJ9sm23O.js → agents-C5FajNPw.js} +2 -2
- package/public/assets/{agents-BJ9sm23O.js.map → agents-C5FajNPw.js.map} +1 -1
- package/public/assets/{automation-CJ2-uiYt.js → automation-ShdXg0pX.js} +2 -2
- package/public/assets/{automation-CJ2-uiYt.js.map → automation-ShdXg0pX.js.map} +1 -1
- package/public/assets/{chat-D0VtHShz.js → chat-aRCk0pLJ.js} +2 -2
- package/public/assets/{chat-D0VtHShz.js.map → chat-aRCk0pLJ.js.map} +1 -1
- package/public/assets/{formatted-body-impl-ChCxpVtI.js → formatted-body-impl-DZc7C4-2.js} +2 -2
- package/public/assets/{formatted-body-impl-ChCxpVtI.js.map → formatted-body-impl-DZc7C4-2.js.map} +1 -1
- package/public/assets/{index-BIRtwnUI.js → index-DXEYz_JV.js} +5 -5
- package/public/assets/{index-BIRtwnUI.js.map → index-DXEYz_JV.js.map} +1 -1
- package/public/assets/{insights-BGuB8DWZ.js → insights-FzTX2wNh.js} +2 -2
- package/public/assets/{insights-BGuB8DWZ.js.map → insights-FzTX2wNh.js.map} +1 -1
- package/public/assets/{maintenance-BFyuZuv5.js → maintenance-C8Uw8XiI.js} +2 -2
- package/public/assets/{maintenance-BFyuZuv5.js.map → maintenance-C8Uw8XiI.js.map} +1 -1
- package/public/assets/{managed-agents-BIXZUkrA.js → managed-agents-DVbJ2hqh.js} +2 -2
- package/public/assets/{managed-agents-BIXZUkrA.js.map → managed-agents-DVbJ2hqh.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DTI--VmH.js → markdown-preview-impl-C5D-B-9S.js} +2 -2
- package/public/assets/{markdown-preview-impl-DTI--VmH.js.map → markdown-preview-impl-C5D-B-9S.js.map} +1 -1
- package/public/assets/{memory-OoM2E30h.js → memory-Bp-D_V80.js} +2 -2
- package/public/assets/{memory-OoM2E30h.js.map → memory-Bp-D_V80.js.map} +1 -1
- package/public/assets/{messages-BHvC3SNR.js → messages-DE1QSeHI.js} +2 -2
- package/public/assets/{messages-BHvC3SNR.js.map → messages-DE1QSeHI.js.map} +1 -1
- package/public/assets/{orchestrators-9pzQqf6-.js → orchestrators-B0u2lQfw.js} +2 -2
- package/public/assets/{orchestrators-9pzQqf6-.js.map → orchestrators-B0u2lQfw.js.map} +1 -1
- package/public/assets/{overview-L8iYOYmR.js → overview-DpaEvvLT.js} +2 -2
- package/public/assets/{overview-L8iYOYmR.js.map → overview-DpaEvvLT.js.map} +1 -1
- package/public/assets/{pairs-BCejxa6_.js → pairs-DRp_qAu8.js} +2 -2
- package/public/assets/{pairs-BCejxa6_.js.map → pairs-DRp_qAu8.js.map} +1 -1
- package/public/assets/{prompt-settings-D9y9hhSy.js → prompt-settings-CNoBiymk.js} +2 -2
- package/public/assets/{prompt-settings-D9y9hhSy.js.map → prompt-settings-CNoBiymk.js.map} +1 -1
- package/public/assets/{registry-BwBg64Vj.js → registry-0A4QFMWC.js} +2 -2
- package/public/assets/{registry-BwBg64Vj.js.map → registry-0A4QFMWC.js.map} +1 -1
- package/public/assets/{security-upd2zf8l.js → security-C9P6yiQ3.js} +2 -2
- package/public/assets/{security-upd2zf8l.js.map → security-C9P6yiQ3.js.map} +1 -1
- package/public/assets/{select-Cu55Vi-R.js → select-B1PVUj6D.js} +2 -2
- package/public/assets/{select-Cu55Vi-R.js.map → select-B1PVUj6D.js.map} +1 -1
- package/public/assets/{settings-Dk5iNqCZ.js → settings-TnHlB9-t.js} +2 -2
- package/public/assets/{settings-Dk5iNqCZ.js.map → settings-TnHlB9-t.js.map} +1 -1
- package/public/assets/{tasks-BoDuvHnZ.js → tasks-1oK_ggFC.js} +2 -2
- package/public/assets/{tasks-BoDuvHnZ.js.map → tasks-1oK_ggFC.js.map} +1 -1
- package/public/assets/{teams-C6Pn-gpw.js → teams-BBb0ZYiF.js} +2 -2
- package/public/assets/{teams-C6Pn-gpw.js.map → teams-BBb0ZYiF.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-afW96xEF.js → terminal-viewer-impl-DzvaRjkp.js} +2 -2
- package/public/assets/{terminal-viewer-impl-afW96xEF.js.map → terminal-viewer-impl-DzvaRjkp.js.map} +1 -1
- package/public/assets/{user-avatar-tSPnqEk5.js → user-avatar-X1pfizLv.js} +2 -2
- package/public/assets/{user-avatar-tSPnqEk5.js.map → user-avatar-X1pfizLv.js.map} +1 -1
- package/public/assets/{work-queue-DP3UdDwC.js → work-queue-DQMbkSD8.js} +2 -2
- package/public/assets/{work-queue-DP3UdDwC.js.map → work-queue-DQMbkSD8.js.map} +1 -1
- package/public/assets/{workspaces-Dt2xndG4.js → workspaces-BNLP4IlV.js} +2 -2
- package/public/assets/{workspaces-Dt2xndG4.js.map → workspaces-BNLP4IlV.js.map} +1 -1
- package/public/index.html +1 -1
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/db/agents.ts +3 -0
- package/src/db/notification-subscriptions.ts +15 -0
- package/src/db/task-entity.ts +2 -0
- package/src/mcp-notification-subscription-tools.ts +2 -1
- package/src/notification-rules.ts +17 -1
- package/src/notification-types.ts +18 -0
- package/src/reviewer-pipeline.ts +13 -0
- package/src/routes/commands.ts +14 -0
- package/src/routes/tasks.ts +1 -0
- package/src/services/spawn-agent.ts +2 -0
- package/src/services/task-entity.ts +20 -6
- package/src/services/task-lifecycle.ts +36 -7
- package/src/services/task-semantic-events.ts +241 -13
- package/src/workspace-auto-merge.ts +17 -0
- package/public/assets/PlanGraphPanel-K6aFJmdd.js +0 -2
- package/public/assets/PlanGraphPanel-K6aFJmdd.js.map +0 -1
package/src/db/task-entity.ts
CHANGED
|
@@ -592,6 +592,7 @@ export interface ListDeliverableTasksFilter {
|
|
|
592
592
|
status?: string;
|
|
593
593
|
deliverableStage?: string;
|
|
594
594
|
teamId?: string;
|
|
595
|
+
createdBy?: string;
|
|
595
596
|
limit?: number;
|
|
596
597
|
}
|
|
597
598
|
|
|
@@ -601,6 +602,7 @@ export function listDeliverableTasks(filter: ListDeliverableTasksFilter = {}): T
|
|
|
601
602
|
if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
|
|
602
603
|
if (filter.deliverableStage) { conditions.push("deliverable_stage = ?"); params.push(filter.deliverableStage); }
|
|
603
604
|
if (filter.teamId) { conditions.push("team_id = ?"); params.push(filter.teamId); }
|
|
605
|
+
if (filter.createdBy) { conditions.push("created_by = ?"); params.push(filter.createdBy); }
|
|
604
606
|
const limit = Math.min(Math.max(filter.limit ?? 100, 1), 500);
|
|
605
607
|
params.push(limit);
|
|
606
608
|
return (getDb().query(`${TASK_SELECT} WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC LIMIT ?`).all(...params as any[]) as any[]).map(rowToTask);
|
|
@@ -21,8 +21,9 @@ import { getTaskEntity } from "./services/task-entity";
|
|
|
21
21
|
import { cleanString } from "./validation";
|
|
22
22
|
import { NOTIFICATION_TYPES, type NotificationScope, type NotificationType } from "./notification-types";
|
|
23
23
|
import { previewNotificationRouting, whyNotified } from "./notification-router";
|
|
24
|
+
import { TASK_SEMANTIC_NOTIFICATION_TYPES } from "./services/task-semantic-events";
|
|
24
25
|
|
|
25
|
-
const TASK_NOTIFICATION_TYPES =
|
|
26
|
+
const TASK_NOTIFICATION_TYPES = TASK_SEMANTIC_NOTIFICATION_TYPES as readonly NotificationType[];
|
|
26
27
|
|
|
27
28
|
export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
|
|
28
29
|
{
|
|
@@ -272,6 +272,22 @@ registerDefaultAudienceRules("message.new", [
|
|
|
272
272
|
audiencePredicates.threadParticipant(),
|
|
273
273
|
]);
|
|
274
274
|
|
|
275
|
-
for (const type of [
|
|
275
|
+
for (const type of [
|
|
276
|
+
"task.created",
|
|
277
|
+
"task.started",
|
|
278
|
+
"task.blocked",
|
|
279
|
+
"task.needs_decision",
|
|
280
|
+
"task.gate_failed",
|
|
281
|
+
"task.committed_on_branch",
|
|
282
|
+
"task.review_requested",
|
|
283
|
+
"task.land_conflict",
|
|
284
|
+
"task.landed",
|
|
285
|
+
"task.landed_on_main",
|
|
286
|
+
"task.closed",
|
|
287
|
+
"task.working",
|
|
288
|
+
"task.done",
|
|
289
|
+
"task.deploying",
|
|
290
|
+
"task.deployed",
|
|
291
|
+
] as const) {
|
|
276
292
|
registerDefaultAudienceRules(type, []);
|
|
277
293
|
}
|
|
@@ -7,8 +7,17 @@ export type NotificationType =
|
|
|
7
7
|
| "task.started"
|
|
8
8
|
| "task.blocked"
|
|
9
9
|
| "task.needs_decision"
|
|
10
|
+
| "task.gate_failed"
|
|
11
|
+
| "task.committed_on_branch"
|
|
12
|
+
| "task.review_requested"
|
|
13
|
+
| "task.land_conflict"
|
|
10
14
|
| "task.landed"
|
|
15
|
+
| "task.landed_on_main"
|
|
11
16
|
| "task.closed"
|
|
17
|
+
| "task.working"
|
|
18
|
+
| "task.done"
|
|
19
|
+
| "task.deploying"
|
|
20
|
+
| "task.deployed"
|
|
12
21
|
| "agent.ready"
|
|
13
22
|
| "agent.exited"
|
|
14
23
|
| "agent.spawn_failed"
|
|
@@ -94,8 +103,17 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
94
103
|
"task.started": null,
|
|
95
104
|
"task.blocked": null,
|
|
96
105
|
"task.needs_decision": null,
|
|
106
|
+
"task.gate_failed": null,
|
|
107
|
+
"task.committed_on_branch": null,
|
|
108
|
+
"task.review_requested": null,
|
|
109
|
+
"task.land_conflict": null,
|
|
97
110
|
"task.landed": null,
|
|
111
|
+
"task.landed_on_main": null,
|
|
98
112
|
"task.closed": null,
|
|
113
|
+
"task.working": null,
|
|
114
|
+
"task.done": null,
|
|
115
|
+
"task.deploying": null,
|
|
116
|
+
"task.deployed": null,
|
|
99
117
|
"agent.ready": null,
|
|
100
118
|
"agent.exited": null,
|
|
101
119
|
"agent.spawn_failed": null,
|
package/src/reviewer-pipeline.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { getSpawnPolicy } from "./config-store";
|
|
|
5
5
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
6
6
|
import { routeNotification } from "./notification-router";
|
|
7
7
|
import { getWorkspace, listAgents, patchWorkspaceMetadata, updateWorkspaceStatus } from "./db";
|
|
8
|
+
import { emitWorkspaceTaskSemanticNotifications } from "./services/task-lifecycle";
|
|
8
9
|
import type { Command, WorkspaceMergePreview, WorkspaceRecord } from "./types";
|
|
9
10
|
|
|
10
11
|
type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED";
|
|
@@ -59,6 +60,18 @@ export function handleReviewerMergeResult(command: Command, result: Record<strin
|
|
|
59
60
|
compareRef,
|
|
60
61
|
});
|
|
61
62
|
if (!updated) return false;
|
|
63
|
+
emitWorkspaceTaskSemanticNotifications({
|
|
64
|
+
workspace: updated,
|
|
65
|
+
transition: "review-requested",
|
|
66
|
+
actor: "reviewer-pipeline",
|
|
67
|
+
occurrenceKey: `workspace-review:${workspace.id}:${command.id}`,
|
|
68
|
+
outcome: {
|
|
69
|
+
reviewer: target,
|
|
70
|
+
prUrl,
|
|
71
|
+
prNumber,
|
|
72
|
+
compareRef,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
62
75
|
|
|
63
76
|
// Obligation (ttl null by type default): a review request must reach the reviewer even if they're
|
|
64
77
|
// offline a while — it's not a bystander notice.
|
package/src/routes/commands.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { getCompactionWatch } from "../compaction-watch";
|
|
|
11
11
|
import { isRecord } from "agent-relay-sdk";
|
|
12
12
|
import { authContextFromRequest } from "../services/auth-context";
|
|
13
13
|
import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
|
|
14
|
+
import { emitWorkspaceTaskSemanticNotifications } from "../services/task-lifecycle";
|
|
14
15
|
import { ServiceAuthError } from "../services/errors";
|
|
15
16
|
import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
|
|
16
17
|
import { applyIdleRefreshResult } from "../maintenance/workspaces";
|
|
@@ -305,6 +306,19 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
305
306
|
mergeError: command.error ?? "merge failed",
|
|
306
307
|
...noteMergeNoProgress(current.metadata),
|
|
307
308
|
});
|
|
309
|
+
if (nextStatus === "conflict") {
|
|
310
|
+
emitWorkspaceTaskSemanticNotifications({
|
|
311
|
+
workspace: current,
|
|
312
|
+
transition: "land-conflict",
|
|
313
|
+
actor: "workspace-merge",
|
|
314
|
+
occurrenceKey: `workspace-merge-conflict:${current.id}:${command.id}`,
|
|
315
|
+
outcome: {
|
|
316
|
+
reason: command.error ?? "merge failed",
|
|
317
|
+
commandId: command.id,
|
|
318
|
+
status: nextStatus,
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
|
308
322
|
notifyBranchLandFailed({
|
|
309
323
|
workspace: current,
|
|
310
324
|
commandId: command.id,
|
package/src/routes/tasks.ts
CHANGED
|
@@ -76,6 +76,7 @@ export const getTaskEntities: Handler = (req) => {
|
|
|
76
76
|
status: url.searchParams.get("status") ?? undefined,
|
|
77
77
|
deliverableStage: url.searchParams.get("deliverableStage") ?? undefined,
|
|
78
78
|
teamId: url.searchParams.get("teamId") ?? undefined,
|
|
79
|
+
createdBy: url.searchParams.get("createdBy") ?? undefined,
|
|
79
80
|
limit: limitRaw ?? 100,
|
|
80
81
|
}));
|
|
81
82
|
};
|
|
@@ -42,6 +42,7 @@ import { applyDefaultSharedMcp, type SharedMcpSpawnOverride } from "../shared-mc
|
|
|
42
42
|
import { autoSeedProjectForCwd } from "./project";
|
|
43
43
|
import { hydrateTaskContext, projectTaskHydration } from "./task-context-hydration";
|
|
44
44
|
import { recordTaskEntityDispatchDecision } from "./task-entity";
|
|
45
|
+
import { autoSubscribeTaskCoordinator } from "./task-semantic-events";
|
|
45
46
|
import { dispatchTask } from "../task-dispatcher";
|
|
46
47
|
import { hasComponentScope, isComponentAuthorizedFor } from "../security";
|
|
47
48
|
import { isPathWithinBase } from "../utils";
|
|
@@ -586,6 +587,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
586
587
|
requestedBy,
|
|
587
588
|
...(hydratedTask?.steer ? { steer: hydratedTask.steer } : {}),
|
|
588
589
|
});
|
|
590
|
+
autoSubscribeTaskCoordinator(requestedBy, input.taskId);
|
|
589
591
|
} catch (err) {
|
|
590
592
|
console.warn(`task ${input.taskId} spawn attribution skipped: ${errMessage(err)}`);
|
|
591
593
|
}
|
|
@@ -29,16 +29,17 @@ import {
|
|
|
29
29
|
} from "../db";
|
|
30
30
|
import { emitTaskChanged } from "../sse";
|
|
31
31
|
import type { Task, TaskDetail, TaskEvent, TaskGateState } from "../types";
|
|
32
|
-
import {
|
|
32
|
+
import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
|
|
33
|
+
import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
|
|
33
34
|
|
|
34
35
|
export function createTaskEntity(input: CreateDeliverableTaskInput): TaskDetail {
|
|
35
36
|
const detail = createDeliverableTask(input);
|
|
37
|
+
autoSubscribeTaskCoordinator(input.createdBy, detail.id);
|
|
36
38
|
emitTaskChanged(detail, "task.created");
|
|
37
39
|
emitTaskSemanticNotification({
|
|
38
40
|
task: detail,
|
|
39
41
|
transition: "created",
|
|
40
42
|
actor: input.createdBy,
|
|
41
|
-
recipients: input.createdBy ? [input.createdBy] : [],
|
|
42
43
|
occurrenceKey: latestTaskEventOccurrence(detail.id, "task.created"),
|
|
43
44
|
});
|
|
44
45
|
return detail;
|
|
@@ -68,23 +69,27 @@ export function setTaskEntityStatus(id: number, input: SetTaskStatusInput): Task
|
|
|
68
69
|
task: detail,
|
|
69
70
|
transition,
|
|
70
71
|
actor: input.actor,
|
|
71
|
-
recipients: detail.createdBy ? [detail.createdBy] : [],
|
|
72
72
|
occurrenceKey: latestTaskEventOccurrence(detail.id, `status:${detail.status}`),
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
|
+
if (isTerminalTaskStatus(detail.status)) removeSubscriptionsForTask(detail.id);
|
|
75
76
|
return detail;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
function isTerminalTaskStatus(status: Task["status"]): boolean {
|
|
80
|
+
return status === "done" || status === "failed" || status === "canceled";
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
export function setTaskEntityStage(id: number, input: SetTaskStageInput): TaskDetail {
|
|
79
84
|
const before = getTaskDetail(id);
|
|
80
85
|
const detail = setTaskStage(id, input);
|
|
81
86
|
emitTaskChanged(detail, "task.stage");
|
|
82
|
-
|
|
87
|
+
const transition = taskTransitionForStage(detail.deliverableStage);
|
|
88
|
+
if (input.actor !== "task-lifecycle" && transition && before?.deliverableStage !== detail.deliverableStage) {
|
|
83
89
|
emitTaskSemanticNotification({
|
|
84
90
|
task: detail,
|
|
85
|
-
transition
|
|
91
|
+
transition,
|
|
86
92
|
actor: input.actor,
|
|
87
|
-
recipients: detail.createdBy ? [detail.createdBy] : [],
|
|
88
93
|
branch: detail.branch,
|
|
89
94
|
occurrenceKey: latestTaskEventOccurrence(detail.id, `stage:${detail.deliverableStage}`),
|
|
90
95
|
});
|
|
@@ -111,6 +116,15 @@ export function recordTaskEntityGate(id: number, input: RecordGateInput): { gate
|
|
|
111
116
|
const gate = recordGate(id, input);
|
|
112
117
|
const detail = getTaskDetail(id);
|
|
113
118
|
if (detail) emitTaskChanged(detail, "task.gate");
|
|
119
|
+
if (detail && gate.status === "fail") {
|
|
120
|
+
emitTaskSemanticNotification({
|
|
121
|
+
task: detail,
|
|
122
|
+
transition: "gate-failed",
|
|
123
|
+
actor: input.actor,
|
|
124
|
+
occurrenceKey: latestTaskEventOccurrence(detail.id, `gate:${gate.gate}`),
|
|
125
|
+
outcome: { gate: gate.gate, status: gate.status, ...(gate.detail ? { detail: gate.detail } : {}) },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
114
128
|
return { gate, gates: detail?.gates ?? [] };
|
|
115
129
|
}
|
|
116
130
|
|
|
@@ -36,7 +36,7 @@ import { requestTransientAgentShutdown } from "./transient-agent-reaper";
|
|
|
36
36
|
import { emitRelayEvent } from "../events";
|
|
37
37
|
import { routeNotification } from "../notification-router";
|
|
38
38
|
import { resolveCoordinatorAgentId } from "../steward-identity";
|
|
39
|
-
import { emitTaskSemanticNotification } from "./task-semantic-events";
|
|
39
|
+
import { emitTaskSemanticNotification, type TaskSemanticTransition } from "./task-semantic-events";
|
|
40
40
|
import type { IssueRef, TaskDeliverableStage, TaskGateState, WorkspaceRecord } from "../types";
|
|
41
41
|
import type { IssueTrackerAdapter } from "../issue-tracker";
|
|
42
42
|
|
|
@@ -179,7 +179,7 @@ function landKeyFor(input: LandedTaskLifecycleInput): string {
|
|
|
179
179
|
// `agent.spawn_requested` attribution keyed on the worker's meta.spawnRequestId (fire-and-forget,
|
|
180
180
|
// owner not yet backfilled), and the Task's recorded branch column. Deduped + ordered. Returns []
|
|
181
181
|
// for an ordinary non-task branch → the land is a strict no-op (behavior byte-identical).
|
|
182
|
-
function
|
|
182
|
+
export function boundTaskIdsForWorkspace(workspace: LifecycleWorkspace): number[] {
|
|
183
183
|
const ids = new Set<number>();
|
|
184
184
|
const owner = workspace.ownerAgentId?.trim();
|
|
185
185
|
if (owner) {
|
|
@@ -195,6 +195,35 @@ function resolveBoundTaskIds(workspace: LifecycleWorkspace): number[] {
|
|
|
195
195
|
return [...ids].sort((a, b) => a - b);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
export function emitWorkspaceTaskSemanticNotifications(input: {
|
|
199
|
+
workspace: LifecycleWorkspace;
|
|
200
|
+
transition: TaskSemanticTransition;
|
|
201
|
+
actor?: string;
|
|
202
|
+
occurrenceKey?: string;
|
|
203
|
+
outcome?: Record<string, unknown>;
|
|
204
|
+
sha?: string;
|
|
205
|
+
}): number[] {
|
|
206
|
+
const emitted: number[] = [];
|
|
207
|
+
const coordinator = resolveCoordinatorAgentId(input.workspace.ownerAgentId ?? undefined);
|
|
208
|
+
for (const taskId of boundTaskIdsForWorkspace(input.workspace)) {
|
|
209
|
+
const task = getTaskDetail(taskId);
|
|
210
|
+
if (!task) continue;
|
|
211
|
+
emitTaskSemanticNotification({
|
|
212
|
+
task,
|
|
213
|
+
transition: input.transition,
|
|
214
|
+
actor: input.actor,
|
|
215
|
+
recipients: coordinator ? [coordinator] : [],
|
|
216
|
+
workspaceId: input.workspace.id,
|
|
217
|
+
branch: input.workspace.branch,
|
|
218
|
+
...(input.sha ? { sha: input.sha } : {}),
|
|
219
|
+
...(input.occurrenceKey ? { occurrenceKey: input.occurrenceKey } : {}),
|
|
220
|
+
...(input.outcome ? { outcome: input.outcome } : {}),
|
|
221
|
+
});
|
|
222
|
+
emitted.push(taskId);
|
|
223
|
+
}
|
|
224
|
+
return emitted;
|
|
225
|
+
}
|
|
226
|
+
|
|
198
227
|
// --- reap signal (decoupled from task-done) ----------------------------------------------------
|
|
199
228
|
interface ReapResult {
|
|
200
229
|
reaped: boolean;
|
|
@@ -382,7 +411,7 @@ function emitDeliverableLandedEvent(input: LandedTaskLifecycleInput, outcome: Ta
|
|
|
382
411
|
const coordinator = resolveCoordinatorAgentId(input.workspace.ownerAgentId ?? undefined);
|
|
383
412
|
emitTaskSemanticNotification({
|
|
384
413
|
task,
|
|
385
|
-
transition: "landed",
|
|
414
|
+
transition: "landed-on-main",
|
|
386
415
|
actor: "task-lifecycle",
|
|
387
416
|
recipients: coordinator ? [coordinator] : [],
|
|
388
417
|
workspaceId: input.workspace.id,
|
|
@@ -401,7 +430,7 @@ function emitDeliverableErrorReceipt(input: LandedTaskLifecycleInput, outcome: T
|
|
|
401
430
|
type: "task.landed",
|
|
402
431
|
scope: {
|
|
403
432
|
taskId: String(outcome.taskId),
|
|
404
|
-
transition: "landed",
|
|
433
|
+
transition: "landed-on-main",
|
|
405
434
|
landKey,
|
|
406
435
|
...(input.workspace.ownerAgentId ? { agentId: input.workspace.ownerAgentId, author: input.workspace.ownerAgentId } : {}),
|
|
407
436
|
...(task?.createdBy ? { parent: task.createdBy } : {}),
|
|
@@ -417,8 +446,8 @@ function emitDeliverableErrorReceipt(input: LandedTaskLifecycleInput, outcome: T
|
|
|
417
446
|
kind: "task.lifecycle-error",
|
|
418
447
|
notification: "task-lifecycle-error",
|
|
419
448
|
taskId: outcome.taskId,
|
|
420
|
-
transition: "landed",
|
|
421
|
-
semanticEvent: "task.landed",
|
|
449
|
+
transition: "landed-on-main",
|
|
450
|
+
semanticEvent: "task.landed-on-main",
|
|
422
451
|
workspaceId: input.workspace.id,
|
|
423
452
|
branch: input.workspace.branch,
|
|
424
453
|
...(input.mergedSha ? { sha: input.mergedSha } : {}),
|
|
@@ -442,7 +471,7 @@ export async function handleLandedTaskLifecycle(
|
|
|
442
471
|
const log = opts.log ?? ((message: string) => console.warn(message));
|
|
443
472
|
// Non-default-base lands never drive a deliverable (mirror the issue-lifecycle guard).
|
|
444
473
|
if ((input.workspace.baseRef ?? "main") !== "main") return { handled: false, reason: "non-default-branch", tasks: [] };
|
|
445
|
-
const taskIds =
|
|
474
|
+
const taskIds = boundTaskIdsForWorkspace(input.workspace);
|
|
446
475
|
if (taskIds.length === 0) return { handled: false, reason: "no-bound-task", tasks: [] };
|
|
447
476
|
|
|
448
477
|
const diffSummary = landedDiffSummary(input, log);
|
|
@@ -1,30 +1,75 @@
|
|
|
1
1
|
import { getAgent } from "../db";
|
|
2
|
+
import { addSubscription } from "../db/notification-subscriptions";
|
|
2
3
|
import { emitRelayEvent } from "../events";
|
|
4
|
+
import { notifySystemMessage } from "../notify";
|
|
3
5
|
import type { NotificationScope, NotificationType } from "../notification-types";
|
|
4
6
|
import { routeNotification } from "../notification-router";
|
|
5
7
|
import { resolveAudience } from "../notification-rules";
|
|
6
|
-
import type { Task, TaskBlocker, TaskStatus } from "../types";
|
|
8
|
+
import type { Task, TaskBlocker, TaskDeliverableStage, TaskDetail, TaskIssueLink, TaskStatus } from "../types";
|
|
7
9
|
|
|
8
|
-
export type TaskSemanticTransition =
|
|
10
|
+
export type TaskSemanticTransition =
|
|
11
|
+
| KnownTaskSemanticTransition
|
|
12
|
+
| (string & {});
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
type KnownTaskSemanticTransition =
|
|
15
|
+
| "created"
|
|
16
|
+
| "working"
|
|
17
|
+
| "blocked"
|
|
18
|
+
| "needs-decision"
|
|
19
|
+
| "gate-failed"
|
|
20
|
+
| "committed-on-branch"
|
|
21
|
+
| "review-requested"
|
|
22
|
+
| "land-conflict"
|
|
23
|
+
| "landed-on-main"
|
|
24
|
+
| "done"
|
|
25
|
+
| "deploying"
|
|
26
|
+
| "deployed";
|
|
27
|
+
|
|
28
|
+
export type TaskSemanticClassification = "action-required" | "status-pulse" | "suppressed";
|
|
29
|
+
|
|
30
|
+
const TASK_NOTIFICATION_TYPE: Record<KnownTaskSemanticTransition, NotificationType> = {
|
|
11
31
|
created: "task.created",
|
|
12
|
-
|
|
32
|
+
working: "task.working",
|
|
13
33
|
blocked: "task.blocked",
|
|
14
34
|
"needs-decision": "task.needs_decision",
|
|
15
|
-
|
|
16
|
-
|
|
35
|
+
"gate-failed": "task.gate_failed",
|
|
36
|
+
"committed-on-branch": "task.committed_on_branch",
|
|
37
|
+
"review-requested": "task.review_requested",
|
|
38
|
+
"land-conflict": "task.land_conflict",
|
|
39
|
+
"landed-on-main": "task.landed_on_main",
|
|
40
|
+
done: "task.done",
|
|
41
|
+
deploying: "task.deploying",
|
|
42
|
+
deployed: "task.deployed",
|
|
17
43
|
};
|
|
44
|
+
const UNKNOWN_TASK_NOTIFICATION_TYPE: NotificationType = "task.working";
|
|
45
|
+
export const TASK_SEMANTIC_NOTIFICATION_TYPES = Object.values(TASK_NOTIFICATION_TYPE);
|
|
18
46
|
|
|
19
47
|
export function taskTransitionForStatus(status: TaskStatus, blockers: TaskBlocker[] | undefined): TaskSemanticTransition | null {
|
|
20
|
-
if (status === "claimed" || status === "in_progress") return "
|
|
48
|
+
if (status === "claimed" || status === "in_progress") return "working";
|
|
21
49
|
if (status === "blocked") {
|
|
22
50
|
return blockers?.some((blocker) => blocker.type === "decision") ? "needs-decision" : "blocked";
|
|
23
51
|
}
|
|
24
|
-
if (status === "done"
|
|
52
|
+
if (status === "done") return "done";
|
|
53
|
+
if (status === "failed") return "gate-failed";
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function taskTransitionForStage(stage: TaskDeliverableStage | undefined): TaskSemanticTransition | null {
|
|
58
|
+
if (stage === "committed-on-branch") return "committed-on-branch";
|
|
59
|
+
if (stage === "landed-on-main") return "landed-on-main";
|
|
60
|
+
if (stage === "deploying") return "deploying";
|
|
61
|
+
if (stage === "deployed") return "deployed";
|
|
25
62
|
return null;
|
|
26
63
|
}
|
|
27
64
|
|
|
65
|
+
export function classifyTaskSemanticTransition(transition: TaskSemanticTransition): TaskSemanticClassification {
|
|
66
|
+
if (transition === "blocked" || transition === "needs-decision" || transition === "gate-failed" || transition === "review-requested" || transition === "land-conflict") {
|
|
67
|
+
return "action-required";
|
|
68
|
+
}
|
|
69
|
+
if (transition === "created") return "suppressed";
|
|
70
|
+
return "status-pulse";
|
|
71
|
+
}
|
|
72
|
+
|
|
28
73
|
export interface TaskSemanticNotificationInput {
|
|
29
74
|
task: Task;
|
|
30
75
|
transition: TaskSemanticTransition;
|
|
@@ -38,15 +83,55 @@ export interface TaskSemanticNotificationInput {
|
|
|
38
83
|
outcome?: Record<string, unknown>;
|
|
39
84
|
}
|
|
40
85
|
|
|
86
|
+
const TASK_STATUS_PULSE_BATCH_MS = 1000;
|
|
87
|
+
const TASK_NOTIFICATION_TRANSITIONS = Object.keys(TASK_NOTIFICATION_TYPE) as KnownTaskSemanticTransition[];
|
|
88
|
+
|
|
89
|
+
interface StatusPulseDigestItem {
|
|
90
|
+
taskId: number;
|
|
91
|
+
title: string;
|
|
92
|
+
transition: TaskSemanticTransition;
|
|
93
|
+
classification: "status-pulse";
|
|
94
|
+
issue?: Pick<TaskIssueLink, "provider" | "repo" | "externalKey" | "url" | "title">;
|
|
95
|
+
branch?: string;
|
|
96
|
+
sha?: string;
|
|
97
|
+
outcome?: Record<string, unknown>;
|
|
98
|
+
occurredAt: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface StatusPulseBatch {
|
|
102
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
103
|
+
items: StatusPulseDigestItem[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const statusPulseBatches = new Map<string, StatusPulseBatch>();
|
|
107
|
+
|
|
108
|
+
export function autoSubscribeTaskCoordinator(agentId: string | undefined, taskId: number): boolean {
|
|
109
|
+
const cleaned = agentId?.trim();
|
|
110
|
+
if (!cleaned || !getAgent(cleaned)) return false;
|
|
111
|
+
for (const transition of TASK_NOTIFICATION_TRANSITIONS) {
|
|
112
|
+
addSubscription({
|
|
113
|
+
agentId: cleaned,
|
|
114
|
+
type: TASK_NOTIFICATION_TYPE[transition],
|
|
115
|
+
mode: "opt_in",
|
|
116
|
+
scopeMatch: { taskId: String(taskId) },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
41
122
|
export function emitTaskSemanticNotification(input: TaskSemanticNotificationInput): void {
|
|
42
|
-
const type = TASK_NOTIFICATION_TYPE[input.transition];
|
|
123
|
+
const type = TASK_NOTIFICATION_TYPE[input.transition as KnownTaskSemanticTransition] ?? UNKNOWN_TASK_NOTIFICATION_TYPE;
|
|
43
124
|
const scope = taskNotificationScope(input);
|
|
44
125
|
const semanticEvent = `task.${input.transition}`;
|
|
126
|
+
const classification = classifyTaskSemanticTransition(input.transition);
|
|
127
|
+
const issue = primaryIssue(input.task);
|
|
45
128
|
const eventData = {
|
|
46
129
|
taskId: input.task.id,
|
|
47
130
|
transition: input.transition,
|
|
131
|
+
classification,
|
|
48
132
|
semanticEvent,
|
|
49
133
|
title: input.task.title,
|
|
134
|
+
...(issue ? { issue } : {}),
|
|
50
135
|
status: input.task.status,
|
|
51
136
|
deliverableStage: input.task.deliverableStage,
|
|
52
137
|
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
|
@@ -72,7 +157,24 @@ export function emitTaskSemanticNotification(input: TaskSemanticNotificationInpu
|
|
|
72
157
|
.map((agentId) => ({ agentId, matchedRule: "task-stakeholder" }));
|
|
73
158
|
|
|
74
159
|
const recipients = [...subscriptionAudience, ...explicit];
|
|
75
|
-
if (recipients.length === 0) return;
|
|
160
|
+
if (recipients.length === 0 || classification === "suppressed") return;
|
|
161
|
+
|
|
162
|
+
if (classification === "status-pulse") {
|
|
163
|
+
for (const recipient of dedupeRecipients(recipients)) {
|
|
164
|
+
enqueueStatusPulse(recipient.agentId, {
|
|
165
|
+
taskId: input.task.id,
|
|
166
|
+
title: input.task.title,
|
|
167
|
+
transition: input.transition,
|
|
168
|
+
classification,
|
|
169
|
+
...(issue ? { issue } : {}),
|
|
170
|
+
...(input.branch ? { branch: input.branch } : {}),
|
|
171
|
+
...(input.sha ? { sha: input.sha } : {}),
|
|
172
|
+
...(input.outcome ? { outcome: input.outcome } : {}),
|
|
173
|
+
occurredAt: Date.now(),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
76
178
|
|
|
77
179
|
routeNotification({
|
|
78
180
|
type,
|
|
@@ -85,9 +187,13 @@ export function emitTaskSemanticNotification(input: TaskSemanticNotificationInpu
|
|
|
85
187
|
payload: {
|
|
86
188
|
kind: "task.transition",
|
|
87
189
|
notification: "task-transition",
|
|
190
|
+
classification,
|
|
191
|
+
actionRequired: true,
|
|
88
192
|
taskId: input.task.id,
|
|
193
|
+
...(issue ? { issue } : {}),
|
|
89
194
|
transition: input.transition,
|
|
90
195
|
semanticEvent,
|
|
196
|
+
needed: taskNotificationNeeded(input),
|
|
91
197
|
...(input.outcome ? { outcome: input.outcome } : {}),
|
|
92
198
|
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
|
93
199
|
...(input.branch ? { branch: input.branch } : {}),
|
|
@@ -99,6 +205,82 @@ export function emitTaskSemanticNotification(input: TaskSemanticNotificationInpu
|
|
|
99
205
|
});
|
|
100
206
|
}
|
|
101
207
|
|
|
208
|
+
function dedupeRecipients(recipients: Array<{ agentId: string; matchedRule: string }>): Array<{ agentId: string; matchedRule: string }> {
|
|
209
|
+
const seen = new Set<string>();
|
|
210
|
+
const out: Array<{ agentId: string; matchedRule: string }> = [];
|
|
211
|
+
for (const recipient of recipients) {
|
|
212
|
+
if (seen.has(recipient.agentId)) continue;
|
|
213
|
+
seen.add(recipient.agentId);
|
|
214
|
+
out.push(recipient);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function enqueueStatusPulse(agentId: string, item: StatusPulseDigestItem): void {
|
|
220
|
+
const batch = statusPulseBatches.get(agentId) ?? { items: [] };
|
|
221
|
+
batch.items.push(item);
|
|
222
|
+
if (!batch.timer) {
|
|
223
|
+
batch.timer = setTimeout(() => flushTaskStatusPulses(agentId), TASK_STATUS_PULSE_BATCH_MS);
|
|
224
|
+
}
|
|
225
|
+
statusPulseBatches.set(agentId, batch);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function flushTaskStatusPulses(agentId?: string): void {
|
|
229
|
+
const targets = agentId ? [agentId] : [...statusPulseBatches.keys()];
|
|
230
|
+
for (const target of targets) {
|
|
231
|
+
const batch = statusPulseBatches.get(target);
|
|
232
|
+
if (!batch) continue;
|
|
233
|
+
if (batch.timer) clearTimeout(batch.timer);
|
|
234
|
+
statusPulseBatches.delete(target);
|
|
235
|
+
if (batch.items.length === 0) continue;
|
|
236
|
+
const items = compactStatusPulseItems(batch.items);
|
|
237
|
+
notifySystemMessage(target, {
|
|
238
|
+
subject: `Task status pulse (${items.length})`,
|
|
239
|
+
body: taskStatusPulseBody(items),
|
|
240
|
+
payload: {
|
|
241
|
+
kind: "task.status-pulse",
|
|
242
|
+
notification: "task-status-pulse",
|
|
243
|
+
classification: "status-pulse",
|
|
244
|
+
actionRequired: false,
|
|
245
|
+
taskIds: [...new Set(items.map((item) => item.taskId))],
|
|
246
|
+
events: items.map((item) => ({
|
|
247
|
+
taskId: item.taskId,
|
|
248
|
+
title: item.title,
|
|
249
|
+
transition: item.transition,
|
|
250
|
+
classification: item.classification,
|
|
251
|
+
...(item.issue ? { issue: item.issue } : {}),
|
|
252
|
+
...(item.branch ? { branch: item.branch } : {}),
|
|
253
|
+
...(item.sha ? { sha: item.sha } : {}),
|
|
254
|
+
...(item.outcome ? { outcome: item.outcome } : {}),
|
|
255
|
+
occurredAt: item.occurredAt,
|
|
256
|
+
})),
|
|
257
|
+
},
|
|
258
|
+
idempotencyKey: `task:status-pulse:${target}:${items.map((item) => `${item.taskId}:${item.transition}:${item.occurredAt}`).join("|")}`,
|
|
259
|
+
replyExpected: false,
|
|
260
|
+
maxAgeSeconds: null,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function compactStatusPulseItems(items: StatusPulseDigestItem[]): StatusPulseDigestItem[] {
|
|
266
|
+
const byKey = new Map<string, StatusPulseDigestItem>();
|
|
267
|
+
for (const item of items) byKey.set(`${item.taskId}:${item.transition}`, item);
|
|
268
|
+
return [...byKey.values()].sort((a, b) => a.occurredAt - b.occurredAt);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function taskStatusPulseBody(items: StatusPulseDigestItem[]): string {
|
|
272
|
+
const lines = ["Task status pulse:"];
|
|
273
|
+
for (const item of items) {
|
|
274
|
+
const bits = [`#${item.taskId}`, item.transition, item.title];
|
|
275
|
+
const issue = issueLabel(item.issue);
|
|
276
|
+
if (issue) bits.push(issue);
|
|
277
|
+
if (item.branch) bits.push(`branch ${item.branch}`);
|
|
278
|
+
if (item.sha) bits.push(`sha ${item.sha.slice(0, 12)}`);
|
|
279
|
+
lines.push(`- ${bits.join(" - ")}`);
|
|
280
|
+
}
|
|
281
|
+
return lines.join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
102
284
|
function taskNotificationIdempotencyKey(input: TaskSemanticNotificationInput): string | undefined {
|
|
103
285
|
if (input.landKey) return `task:${input.task.id}:transition:${input.transition}:${input.landKey}`;
|
|
104
286
|
if (input.occurrenceKey) return `task:${input.task.id}:transition:${input.transition}:occurrence:${input.occurrenceKey}`;
|
|
@@ -125,13 +307,59 @@ function taskNotificationSubject(input: TaskSemanticNotificationInput): string {
|
|
|
125
307
|
}
|
|
126
308
|
|
|
127
309
|
function taskNotificationBody(input: TaskSemanticNotificationInput): string {
|
|
128
|
-
|
|
129
|
-
|
|
310
|
+
const issue = issueLabel(primaryIssue(input.task));
|
|
311
|
+
if (input.transition === "landed-on-main") {
|
|
312
|
+
const bits = [`Task #${input.task.id} landed on main: ${input.task.title}`];
|
|
130
313
|
if (input.sha) bits.push(`sha ${input.sha.slice(0, 12)}`);
|
|
131
314
|
const gatesPassed = input.outcome?.gatesPassed;
|
|
132
315
|
if (typeof gatesPassed === "boolean") bits.push(gatesPassed ? "gates passed" : "gates not verified");
|
|
133
316
|
if (input.outcome?.statusAdvanced === true) bits.push("closed");
|
|
317
|
+
if (issue) bits.push(issue);
|
|
134
318
|
return bits.join("; ");
|
|
135
319
|
}
|
|
136
|
-
|
|
320
|
+
const needed = taskNotificationNeeded(input);
|
|
321
|
+
return `Task #${input.task.id} ${input.transition}: ${input.task.title}${issue ? ` (${issue})` : ""}${needed ? `\nNeeded: ${needed}` : ""}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function taskNotificationNeeded(input: TaskSemanticNotificationInput): string {
|
|
325
|
+
if (input.transition === "needs-decision") return "coordinator decision";
|
|
326
|
+
if (input.transition === "blocked") return blockedReason(input.task) ?? "unblock the worker";
|
|
327
|
+
if (input.transition === "gate-failed") return gateFailureReason(input.outcome) ?? "inspect failed gate";
|
|
328
|
+
if (input.transition === "review-requested") return "review requested";
|
|
329
|
+
if (input.transition === "land-conflict") return "land conflict needs resolution";
|
|
330
|
+
return "";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function blockedReason(task: Task): string | undefined {
|
|
334
|
+
const blockers = task.blockedBy ?? [];
|
|
335
|
+
if (blockers.length === 0) return undefined;
|
|
336
|
+
return blockers
|
|
337
|
+
.map((blocker) => [blocker.type, blocker.ref, blocker.note].filter(Boolean).join(": "))
|
|
338
|
+
.join("; ");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function gateFailureReason(outcome: Record<string, unknown> | undefined): string | undefined {
|
|
342
|
+
const gate = typeof outcome?.gate === "string" ? outcome.gate : undefined;
|
|
343
|
+
const detail = typeof outcome?.detail === "string" ? outcome.detail : undefined;
|
|
344
|
+
if (gate && detail) return `${gate} failed: ${detail}`;
|
|
345
|
+
if (gate) return `${gate} failed`;
|
|
346
|
+
return detail;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function primaryIssue(task: Task): StatusPulseDigestItem["issue"] | undefined {
|
|
350
|
+
const issues = (task as Partial<TaskDetail>).issues;
|
|
351
|
+
const issue = Array.isArray(issues) ? issues[0] : undefined;
|
|
352
|
+
if (!issue) return undefined;
|
|
353
|
+
return {
|
|
354
|
+
provider: issue.provider,
|
|
355
|
+
repo: issue.repo,
|
|
356
|
+
externalKey: issue.externalKey,
|
|
357
|
+
url: issue.url,
|
|
358
|
+
...(issue.title ? { title: issue.title } : {}),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function issueLabel(issue: StatusPulseDigestItem["issue"] | undefined): string {
|
|
363
|
+
if (!issue) return "";
|
|
364
|
+
return `${issue.repo}#${issue.externalKey}`;
|
|
137
365
|
}
|