agent-relay-server 0.107.0 → 0.109.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 +589 -1
- package/package.json +4 -4
- package/public/assets/MessageBubble-XgN61quY.js +4 -0
- package/public/assets/{MessageBubble-PEdvVh_m.js.map → MessageBubble-XgN61quY.js.map} +1 -1
- package/public/assets/PlanGraphPanel-LY-msOsr.js +2 -0
- package/public/assets/PlanGraphPanel-LY-msOsr.js.map +1 -0
- package/public/assets/activity-D0EotVU9.js +2 -0
- package/public/assets/activity-D0EotVU9.js.map +1 -0
- package/public/assets/{agent-profiles-CuTQtmV6.js → agent-profiles-CAGvryb9.js} +2 -2
- package/public/assets/{agent-profiles-CuTQtmV6.js.map → agent-profiles-CAGvryb9.js.map} +1 -1
- package/public/assets/agents-CXXZ_sU9.js +2 -0
- package/public/assets/{agents-CgRFJyks.js.map → agents-CXXZ_sU9.js.map} +1 -1
- package/public/assets/{analytics-C3OTFIXo.js → analytics-Q6Dl_CE9.js} +3 -3
- package/public/assets/{analytics-C3OTFIXo.js.map → analytics-Q6Dl_CE9.js.map} +1 -1
- package/public/assets/{automation-CH9YGYoE.js → automation-zig0cp23.js} +2 -2
- package/public/assets/{automation-CH9YGYoE.js.map → automation-zig0cp23.js.map} +1 -1
- package/public/assets/{badge-D8fABzMs.js → badge-kdQDWO6e.js} +2 -2
- package/public/assets/{badge-D8fABzMs.js.map → badge-kdQDWO6e.js.map} +1 -1
- package/public/assets/{branch-state-badge-CcLZ_u86.js → branch-state-badge-BRjUWyMT.js} +2 -2
- package/public/assets/{branch-state-badge-CcLZ_u86.js.map → branch-state-badge-BRjUWyMT.js.map} +1 -1
- package/public/assets/{button-KASZcscA.js → button-BKFvNXa3.js} +2 -2
- package/public/assets/{button-KASZcscA.js.map → button-BKFvNXa3.js.map} +1 -1
- package/public/assets/card-B0y1i_2D.js +2 -0
- package/public/assets/{card-Cds0MWME.js.map → card-B0y1i_2D.js.map} +1 -1
- package/public/assets/{channels-CcSnLzCG.js → channels-DOYB1iiA.js} +2 -2
- package/public/assets/{channels-CcSnLzCG.js.map → channels-DOYB1iiA.js.map} +1 -1
- package/public/assets/chat-C3QlsFBd.js +2 -0
- package/public/assets/{chat-BLcm2Aht.js.map → chat-C3QlsFBd.js.map} +1 -1
- package/public/assets/{connectors-DbPOlQhL.js → connectors-C5CeK-2M.js} +2 -2
- package/public/assets/{connectors-DbPOlQhL.js.map → connectors-C5CeK-2M.js.map} +1 -1
- package/public/assets/{copy-button-d9DFNX9v.js → copy-button-aLaiAkv2.js} +2 -2
- package/public/assets/{copy-button-d9DFNX9v.js.map → copy-button-aLaiAkv2.js.map} +1 -1
- package/public/assets/display-_5kFrHJh.js +3 -0
- package/public/assets/display-_5kFrHJh.js.map +1 -0
- package/public/assets/{dist-DOAjFOS4.js → dist-BDYU37NZ.js} +2 -2
- package/public/assets/{dist-DOAjFOS4.js.map → dist-BDYU37NZ.js.map} +1 -1
- package/public/assets/dist-BnfqT2cl.js +2 -0
- package/public/assets/{dist-DC-TqTz2.js.map → dist-BnfqT2cl.js.map} +1 -1
- package/public/assets/dist-CF28Y5wI.js +2 -0
- package/public/assets/{dist-CBVZk9U8.js.map → dist-CF28Y5wI.js.map} +1 -1
- package/public/assets/{dist-DTXKbk2K.js → dist-DDh8a_SK.js} +2 -2
- package/public/assets/{dist-DTXKbk2K.js.map → dist-DDh8a_SK.js.map} +1 -1
- package/public/assets/{dist-BbUJ1Vht.js → dist-DKoRwEZq.js} +2 -2
- package/public/assets/{dist-BbUJ1Vht.js.map → dist-DKoRwEZq.js.map} +1 -1
- package/public/assets/{dist-UhEAFdvZ.js → dist-DQPz52Zx.js} +2 -2
- package/public/assets/{dist-UhEAFdvZ.js.map → dist-DQPz52Zx.js.map} +1 -1
- package/public/assets/{dist-CgqfdHSt.js → dist-D_qcpfbI.js} +2 -2
- package/public/assets/{dist-CgqfdHSt.js.map → dist-D_qcpfbI.js.map} +1 -1
- package/public/assets/{dist-DybScJmm.js → dist-DnyQI0qt.js} +2 -2
- package/public/assets/{dist-DybScJmm.js.map → dist-DnyQI0qt.js.map} +1 -1
- package/public/assets/{dist-BwwlmyBq.js → dist-EO8jbi2F.js} +2 -2
- package/public/assets/{dist-BwwlmyBq.js.map → dist-EO8jbi2F.js.map} +1 -1
- package/public/assets/{dist-BlZvUZZB.js → dist-avJPT3qF.js} +2 -2
- package/public/assets/{dist-BlZvUZZB.js.map → dist-avJPT3qF.js.map} +1 -1
- package/public/assets/dist-cyag2tir.js +2 -0
- package/public/assets/{dist-DreO5AcZ.js.map → dist-cyag2tir.js.map} +1 -1
- package/public/assets/{es2015-DUjFyjDp.js → es2015-C9FSEGd7.js} +2 -2
- package/public/assets/{es2015-DUjFyjDp.js.map → es2015-C9FSEGd7.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BJTthVHg.js → formatted-body-impl-DCQSVyWB.js} +2 -2
- package/public/assets/{formatted-body-impl-BJTthVHg.js.map → formatted-body-impl-DCQSVyWB.js.map} +1 -1
- package/public/assets/index-D6hQkte1.css +2 -0
- package/public/assets/index-RCbIyk26.js +23 -0
- package/public/assets/index-RCbIyk26.js.map +1 -0
- package/public/assets/{input-CtHfLn1i.js → input-DfN6sUbX.js} +2 -2
- package/public/assets/{input-CtHfLn1i.js.map → input-DfN6sUbX.js.map} +1 -1
- package/public/assets/{insights-DklrIhb6.js → insights-CsLs6NbL.js} +2 -2
- package/public/assets/{insights-DklrIhb6.js.map → insights-CsLs6NbL.js.map} +1 -1
- package/public/assets/{integrations-hkSW8CzM.js → integrations-CztQfrI7.js} +2 -2
- package/public/assets/{integrations-hkSW8CzM.js.map → integrations-CztQfrI7.js.map} +1 -1
- package/public/assets/{lib-l-JkMVR9.js → lib-DRRHEqRZ.js} +2 -2
- package/public/assets/{lib-l-JkMVR9.js.map → lib-DRRHEqRZ.js.map} +1 -1
- package/public/assets/{lucide-react-C4eXYCwx.js → lucide-react-DmbfpRvJ.js} +2 -2
- package/public/assets/{lucide-react-C4eXYCwx.js.map → lucide-react-DmbfpRvJ.js.map} +1 -1
- package/public/assets/{maintenance-FAAMGpuZ.js → maintenance-BN5F0Lnc.js} +2 -2
- package/public/assets/{maintenance-FAAMGpuZ.js.map → maintenance-BN5F0Lnc.js.map} +1 -1
- package/public/assets/managed-agents-R_LI9qS5.js +4 -0
- package/public/assets/{managed-agents-CVca-s3t.js.map → managed-agents-R_LI9qS5.js.map} +1 -1
- package/public/assets/markdown-preview-impl-DQlmK_ev.js +2 -0
- package/public/assets/{markdown-preview-impl-BPW4OQo9.js.map → markdown-preview-impl-DQlmK_ev.js.map} +1 -1
- package/public/assets/memory-kn4bgMXC.js +2 -0
- package/public/assets/{memory-BzuDTHC5.js.map → memory-kn4bgMXC.js.map} +1 -1
- package/public/assets/{messages-_W24PNvT.js → messages-Dv3Tv7zC.js} +2 -2
- package/public/assets/{messages-_W24PNvT.js.map → messages-Dv3Tv7zC.js.map} +1 -1
- package/public/assets/{orchestrators-Du6I_n8i.js → orchestrators-DcNLRMzk.js} +2 -2
- package/public/assets/{orchestrators-Du6I_n8i.js.map → orchestrators-DcNLRMzk.js.map} +1 -1
- package/public/assets/overview-BOLzPSSC.js +2 -0
- package/public/assets/{overview-Dyvm1qpT.js.map → overview-BOLzPSSC.js.map} +1 -1
- package/public/assets/{pairs-B8x1RCNd.js → pairs-B4r0egpx.js} +2 -2
- package/public/assets/{pairs-B8x1RCNd.js.map → pairs-B4r0egpx.js.map} +1 -1
- package/public/assets/projects-cOeuiMjl.js +2 -0
- package/public/assets/{projects-vt63bwHN.js.map → projects-cOeuiMjl.js.map} +1 -1
- package/public/assets/prompt-settings-E7-6Osj4.js +2 -0
- package/public/assets/{prompt-settings-xVXmvxB5.js.map → prompt-settings-E7-6Osj4.js.map} +1 -1
- package/public/assets/{react-dom-BMF4hNzG.js → react-dom-jQ4Vs380.js} +2 -2
- package/public/assets/{react-dom-BMF4hNzG.js.map → react-dom-jQ4Vs380.js.map} +1 -1
- package/public/assets/registry-CTN3jOnb.js +2 -0
- package/public/assets/{registry-ypiICauZ.js.map → registry-CTN3jOnb.js.map} +1 -1
- package/public/assets/{security-BfOQ6T5r.js → security-BctIcBZb.js} +2 -2
- package/public/assets/{security-BfOQ6T5r.js.map → security-BctIcBZb.js.map} +1 -1
- package/public/assets/{select-Dx8Ro-A3.js → select-BndvVb0A.js} +2 -2
- package/public/assets/{select-Dx8Ro-A3.js.map → select-BndvVb0A.js.map} +1 -1
- package/public/assets/settings-B_tRq-P4.js +6 -0
- package/public/assets/{settings-87clTx4N.js.map → settings-B_tRq-P4.js.map} +1 -1
- package/public/assets/store-DDWg67Pg.js +9 -0
- package/public/assets/store-DDWg67Pg.js.map +1 -0
- package/public/assets/tasks-BgTTxVET.js +2 -0
- package/public/assets/tasks-BgTTxVET.js.map +1 -0
- package/public/assets/teams-DJiLBEgF.js +2 -0
- package/public/assets/{teams-DJWp4AKi.js.map → teams-DJiLBEgF.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DgMuRAe_.js → terminal-viewer-impl-BLdiyCNR.js} +3 -3
- package/public/assets/{terminal-viewer-impl-DgMuRAe_.js.map → terminal-viewer-impl-BLdiyCNR.js.map} +1 -1
- package/public/assets/types-DSwhOFcJ.js +2 -0
- package/public/assets/types-DSwhOFcJ.js.map +1 -0
- package/public/assets/{use-keyboard-viewport-RYMALvu1.js → use-keyboard-viewport-Dw2CHZ50.js} +2 -2
- package/public/assets/{use-keyboard-viewport-RYMALvu1.js.map → use-keyboard-viewport-Dw2CHZ50.js.map} +1 -1
- package/public/assets/user-avatar-rt59cEsc.js +2 -0
- package/public/assets/{user-avatar-CfAUS3PZ.js.map → user-avatar-rt59cEsc.js.map} +1 -1
- package/public/assets/{work-queue-D-Otxd9u.js → work-queue-BYRdBuLR.js} +2 -2
- package/public/assets/{work-queue-D-Otxd9u.js.map → work-queue-BYRdBuLR.js.map} +1 -1
- package/public/assets/workspaces-DCRT3l9H.js +3 -0
- package/public/assets/workspaces-DCRT3l9H.js.map +1 -0
- package/public/index.html +23 -23
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +6 -0
- package/src/branch-landed.ts +17 -3
- package/src/config-store.ts +12 -1
- package/src/db/agents.ts +3 -2
- package/src/db/continuations.ts +13 -3
- package/src/db/index.ts +3 -0
- package/src/db/issue-lifecycle.ts +12 -0
- package/src/db/mappers.ts +17 -0
- package/src/db/messages.ts +9 -4
- package/src/db/migrations.ts +112 -2
- package/src/db/notification-subscriptions.ts +1 -1
- package/src/db/prompt-templates.ts +19 -0
- package/src/db/schema.ts +74 -2
- package/src/db/stats.ts +3 -2
- package/src/db/task-comment-through.ts +112 -0
- package/src/db/task-core.ts +14 -1
- package/src/db/task-entity.ts +629 -0
- package/src/db/task-maintenance.ts +4 -3
- package/src/db/task-memory.ts +180 -0
- package/src/db/tasks.ts +24 -10
- package/src/issue-tracker/adapter.ts +15 -0
- package/src/issue-tracker/github.ts +40 -2
- package/src/issue-tracker/index.ts +1 -0
- package/src/mcp/index.ts +12 -1
- package/src/mcp/tools-spawn.ts +4 -0
- package/src/mcp-dispatch-tools.ts +193 -0
- package/src/mcp-notification-subscription-tools.ts +62 -4
- package/src/mcp-plan-tools.ts +30 -3
- package/src/mcp-release-tools.ts +33 -0
- package/src/mcp-task-memory-tools.ts +115 -0
- package/src/mcp-task-tools.ts +232 -0
- package/src/mcp-task-weave-tools.ts +66 -0
- package/src/notification-router.ts +24 -2
- package/src/notification-rules.ts +4 -0
- package/src/notification-types.ts +23 -0
- package/src/prompt-resolver.ts +66 -3
- package/src/routes/agents-spawn.ts +10 -0
- package/src/routes/index.ts +17 -1
- package/src/routes/messages.ts +6 -6
- package/src/routes/plans.ts +10 -2
- package/src/routes/tasks.ts +335 -2
- package/src/routing-policy-store.ts +147 -2
- package/src/security.ts +4 -0
- package/src/self-resume.ts +13 -2
- package/src/services/issue-lifecycle.ts +9 -1
- package/src/services/plan-graph.ts +107 -9
- package/src/services/release-readiness.ts +142 -0
- package/src/services/send-message.ts +31 -0
- package/src/services/spawn-agent.ts +81 -4
- package/src/services/task-context-hydration.ts +361 -0
- package/src/services/task-entity.ts +149 -0
- package/src/services/task-issue-weave.ts +558 -0
- package/src/services/task-lifecycle.ts +465 -0
- package/src/services/task-plan-view.ts +178 -0
- package/src/services/task-semantic-events.ts +137 -0
- package/src/services/transient-agent-reaper.ts +55 -22
- package/src/spawn-targets.ts +27 -2
- package/src/task-authz.ts +52 -0
- package/src/task-dispatcher.ts +461 -0
- package/src/upgrade-install-verify.ts +20 -1
- package/public/assets/MessageBubble-PEdvVh_m.js +0 -4
- package/public/assets/PlanGraphPanel-DSts7Rtq.js +0 -2
- package/public/assets/PlanGraphPanel-DSts7Rtq.js.map +0 -1
- package/public/assets/activity-BcYf8YNO.js +0 -2
- package/public/assets/activity-BcYf8YNO.js.map +0 -1
- package/public/assets/agents-CgRFJyks.js +0 -2
- package/public/assets/card-Cds0MWME.js +0 -2
- package/public/assets/chat-BLcm2Aht.js +0 -2
- package/public/assets/display-BWi8DQCp.js +0 -3
- package/public/assets/display-BWi8DQCp.js.map +0 -1
- package/public/assets/dist-CBVZk9U8.js +0 -2
- package/public/assets/dist-DC-TqTz2.js +0 -2
- package/public/assets/dist-DreO5AcZ.js +0 -2
- package/public/assets/index-3ydeNO0c.js +0 -23
- package/public/assets/index-3ydeNO0c.js.map +0 -1
- package/public/assets/index-BjjzI2LT.css +0 -2
- package/public/assets/managed-agents-CVca-s3t.js +0 -4
- package/public/assets/markdown-preview-impl-BPW4OQo9.js +0 -2
- package/public/assets/memory-BzuDTHC5.js +0 -2
- package/public/assets/overview-Dyvm1qpT.js +0 -2
- package/public/assets/projects-vt63bwHN.js +0 -2
- package/public/assets/prompt-settings-xVXmvxB5.js +0 -2
- package/public/assets/registry-ypiICauZ.js +0 -2
- package/public/assets/settings-87clTx4N.js +0 -6
- package/public/assets/store-DUfrGvOE.js +0 -9
- package/public/assets/store-DUfrGvOE.js.map +0 -1
- package/public/assets/tasks-1Kvp53wh.js +0 -2
- package/public/assets/tasks-1Kvp53wh.js.map +0 -1
- package/public/assets/teams-DJWp4AKi.js +0 -2
- package/public/assets/types-DorDDvEb.js +0 -2
- package/public/assets/types-DorDDvEb.js.map +0 -1
- package/public/assets/user-avatar-CfAUS3PZ.js +0 -2
- package/public/assets/workspaces-CIsQqQby.js +0 -3
- package/public/assets/workspaces-CIsQqQby.js.map +0 -1
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
// #833 — the Task ↔ GitHub issue weave-through. This is the WRITE-THROUGH lens that
|
|
2
|
+
// the design refinement (weave-in, not duplicate) calls for: a comment on a Task does
|
|
3
|
+
// not land in a relay table that can drift — it writes a structured comment THROUGH to
|
|
4
|
+
// the linked GitHub issue, the backing store for human discussion. GitHub stays the
|
|
5
|
+
// system of record for intent/spec/acceptance/discussion; the Task is the lens + the
|
|
6
|
+
// write-through API.
|
|
7
|
+
//
|
|
8
|
+
// FIELD-OWNERSHIP BOUNDARY (the critical hazard #830/#833 guards against):
|
|
9
|
+
// - GitHub owns: intent / spec / acceptance / human discussion → write THROUGH here.
|
|
10
|
+
// - Task owns: execution telemetry (status, stage, gate ledger, branch, commits,
|
|
11
|
+
// attribution) → stays Task-only, in src/db/task-entity.ts.
|
|
12
|
+
//
|
|
13
|
+
// GUARD (required): execution telemetry must NEVER write through to GitHub — a comment
|
|
14
|
+
// per gate-pass / per-position-change is noise + a rate-limit suicide. The guard is
|
|
15
|
+
// structural: the telemetry writers (setTaskStatus/setTaskStage/recordGate/…) live in
|
|
16
|
+
// the db layer and DO NOT import this module, so they have no path to the adapter. The
|
|
17
|
+
// ONE chokepoint that touches the issue tracker is `postThrough` below, reachable only
|
|
18
|
+
// via `commentOnTaskThrough` (human/agent discussion) and
|
|
19
|
+
// `postTaskMilestoneComment` (the single milestone-summary exception). `assertWriteThroughEligible`
|
|
20
|
+
// makes the classification a hard, tested invariant.
|
|
21
|
+
//
|
|
22
|
+
// MILESTONE EXCEPTION: a single structured summary MAY post at a meaningful boundary
|
|
23
|
+
// (e.g. on land). The mechanism is `postTaskMilestoneComment`; the land TRIGGER that
|
|
24
|
+
// calls it is #831's job — left here as a seam, not wired into the land pipeline.
|
|
25
|
+
//
|
|
26
|
+
// CLOSE-BRIDGE: #482/#474 already close the linked issue exactly once on land and record
|
|
27
|
+
// it durably in `issue_lifecycle_actions`. The Task does not duplicate that ledger — it
|
|
28
|
+
// PROJECTS it (`taskCloseBridge`), closing the "I couldn't tell if #482 already closed it"
|
|
29
|
+
// gap by surfacing the canonical record instead of copying it.
|
|
30
|
+
import { errMessage } from "agent-relay-sdk";
|
|
31
|
+
import { getTaskDetail } from "../db/task-entity";
|
|
32
|
+
import { getIssueRef } from "../db/issue-refs";
|
|
33
|
+
import { listSuccessfulCloseActionsForIssueRef } from "../db/issue-lifecycle";
|
|
34
|
+
import { insertTaskEvent, getTaskEvent } from "../db/task-core";
|
|
35
|
+
import { listTaskEvents } from "../db/tasks";
|
|
36
|
+
import {
|
|
37
|
+
failCommentThrough,
|
|
38
|
+
finalizeCommentThrough,
|
|
39
|
+
reserveCommentThrough,
|
|
40
|
+
type TaskCommentThroughKind,
|
|
41
|
+
type TaskCommentThroughRecord,
|
|
42
|
+
} from "../db/task-comment-through";
|
|
43
|
+
import { resolveIssueTrackerAdapter, type IssueTrackerAdapter } from "../issue-tracker";
|
|
44
|
+
import { emitTaskChanged } from "../sse";
|
|
45
|
+
import { ValidationError } from "../db/connection";
|
|
46
|
+
import type { IssueRef, TaskDetail, TaskEvent, TaskGateState, TaskIssueLink } from "../types";
|
|
47
|
+
|
|
48
|
+
type AdapterResolver = (ref: IssueRef) => IssueTrackerAdapter;
|
|
49
|
+
type LogFn = (message: string) => void;
|
|
50
|
+
|
|
51
|
+
interface WeaveOptions {
|
|
52
|
+
adapterResolver?: AdapterResolver;
|
|
53
|
+
log?: LogFn;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let adapterResolverForTests: AdapterResolver | undefined;
|
|
57
|
+
|
|
58
|
+
export function __setTaskWeaveAdapterResolverForTests(resolver: AdapterResolver | undefined): void {
|
|
59
|
+
adapterResolverForTests = resolver;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function adapterFor(ref: IssueRef, opts: WeaveOptions): IssueTrackerAdapter {
|
|
63
|
+
return (opts.adapterResolver ?? adapterResolverForTests ?? resolveIssueTrackerAdapter)(ref);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Structured comment format -------------------------------------------------------
|
|
67
|
+
// A relay-written issue comment carries an HTML-comment marker holding a JSON payload so
|
|
68
|
+
// it is machine-parseable (attributable back to its Task + author + kind) while still
|
|
69
|
+
// rendering as a normal human comment on GitHub. The two kinds are the ONLY things that
|
|
70
|
+
// ever write through — discussion (a comment on the Task) and milestone (the boundary
|
|
71
|
+
// summary). Telemetry has no kind here on purpose.
|
|
72
|
+
export const TASK_COMMENT_MARKER = "agent-relay:task-comment";
|
|
73
|
+
export const TASK_COMMENT_FORMAT_VERSION = 1;
|
|
74
|
+
export type TaskCommentKind = "discussion" | "milestone";
|
|
75
|
+
|
|
76
|
+
// The event types this module appends to the Task ledger as a RECEIPT of a write-through.
|
|
77
|
+
// Everything else (status:*, stage:*, gate:*, dependency.*, ref.*, agent.*) is telemetry
|
|
78
|
+
// and must never reach the chokepoint.
|
|
79
|
+
export const WRITE_THROUGH_EVENT_TYPES = ["comment.posted", "milestone.posted"] as const;
|
|
80
|
+
const TELEMETRY_EVENT_PREFIXES = ["status:", "stage:", "gate:", "dependency.", "ref.", "agent.", "task."];
|
|
81
|
+
|
|
82
|
+
// Hard guard for the field-ownership boundary: only discussion/milestone write-throughs
|
|
83
|
+
// are eligible. Anything telemetry-shaped throws — so a future caller cannot accidentally
|
|
84
|
+
// route a gate/position event through the issue tracker.
|
|
85
|
+
export function isWriteThroughEligibleKind(kind: string): kind is TaskCommentKind {
|
|
86
|
+
return kind === "discussion" || kind === "milestone";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isTelemetryEventType(type: string): boolean {
|
|
90
|
+
return TELEMETRY_EVENT_PREFIXES.some((prefix) => type.startsWith(prefix));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function assertWriteThroughEligible(kind: string): asserts kind is TaskCommentKind {
|
|
94
|
+
if (!isWriteThroughEligibleKind(kind)) {
|
|
95
|
+
throw new ValidationError(
|
|
96
|
+
`task-issue weave: '${kind}' is not write-through eligible — execution telemetry stays Task-only and must never post to GitHub`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface TaskCommentPayload {
|
|
102
|
+
v: number;
|
|
103
|
+
kind: TaskCommentKind;
|
|
104
|
+
taskId: number;
|
|
105
|
+
author?: string;
|
|
106
|
+
milestone?: string;
|
|
107
|
+
// The idempotency/reservation key (clientId for discussion, `${milestone}:${key}` for
|
|
108
|
+
// milestone). Embedded so a post-before-finalize crash can be RECONCILED: on reclaiming a
|
|
109
|
+
// stale reservation, we read the issue's comments and match this `key` to learn whether the
|
|
110
|
+
// comment already landed — instead of blindly re-posting it.
|
|
111
|
+
key?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const HEADINGS: Record<TaskCommentKind, (p: TaskCommentPayload) => string> = {
|
|
115
|
+
discussion: (p) => `💬 **Comment on Task #${p.taskId}**${p.author ? ` — _${p.author}_` : ""}`,
|
|
116
|
+
milestone: (p) => `🏁 **Task #${p.taskId} — ${p.milestone ?? "milestone"}**`,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// The payload is base64url-encoded inside the marker, NOT raw JSON: a user/route-supplied
|
|
120
|
+
// author or milestone containing `}` or `-->` would otherwise break the greedy `{...}` scan
|
|
121
|
+
// or terminate the HTML comment early. base64url's alphabet ([A-Za-z0-9_-]) cannot contain
|
|
122
|
+
// either, so the marker is always delimiter-safe regardless of payload content.
|
|
123
|
+
const MARKER_RE = new RegExp(`^<!--\\s*${TASK_COMMENT_MARKER}\\s+b64=([A-Za-z0-9_-]+)\\s*-->`);
|
|
124
|
+
|
|
125
|
+
export function formatTaskComment(payload: TaskCommentPayload, body: string): string {
|
|
126
|
+
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
127
|
+
const marker = `<!-- ${TASK_COMMENT_MARKER} b64=${encoded} -->`;
|
|
128
|
+
return `${marker}\n${HEADINGS[payload.kind](payload)}\n\n${body.trim()}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Round-trip parser (used by the dashboard lens + tests): pull the relay marker back out
|
|
132
|
+
// of a rendered comment so a relay-written comment is unambiguously identifiable. Parsed
|
|
133
|
+
// only from the leading marker block (anchored `^`), never a greedy scan of the body, and
|
|
134
|
+
// the decoded payload's v/taskId/kind are type-validated before it is trusted.
|
|
135
|
+
export function parseTaskComment(comment: string): TaskCommentPayload | null {
|
|
136
|
+
const match = comment.match(MARKER_RE);
|
|
137
|
+
if (!match) return null;
|
|
138
|
+
try {
|
|
139
|
+
const parsed = JSON.parse(Buffer.from(match[1]!, "base64url").toString("utf8")) as unknown;
|
|
140
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
141
|
+
const p = parsed as Record<string, unknown>;
|
|
142
|
+
if (typeof p.v !== "number" || typeof p.taskId !== "number" || typeof p.kind !== "string") return null;
|
|
143
|
+
if (!isWriteThroughEligibleKind(p.kind)) return null;
|
|
144
|
+
return {
|
|
145
|
+
v: p.v,
|
|
146
|
+
kind: p.kind,
|
|
147
|
+
taskId: p.taskId,
|
|
148
|
+
...(typeof p.author === "string" ? { author: p.author } : {}),
|
|
149
|
+
...(typeof p.milestone === "string" ? { milestone: p.milestone } : {}),
|
|
150
|
+
...(typeof p.key === "string" ? { key: p.key } : {}),
|
|
151
|
+
};
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- The single chokepoint that touches the issue tracker ----------------------------
|
|
158
|
+
interface WriteThroughResult {
|
|
159
|
+
posted: TaskIssueLink[];
|
|
160
|
+
failed: Array<{ link: TaskIssueLink; error: string }>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Writes `body` through to every GitHub-backed issue linked to the Task. This is the ONLY
|
|
164
|
+
// function in the codebase that posts a Task-originated comment to an issue tracker. It is
|
|
165
|
+
// kind-gated (assertWriteThroughEligible) so telemetry can never be smuggled through.
|
|
166
|
+
// Non-GitHub providers are skipped (the design's backing store for discussion is GitHub);
|
|
167
|
+
// a tracker failure on one issue does not abort the others.
|
|
168
|
+
// Posts `body` through to the GitHub-backed links in `links` (a tracker failure on one issue
|
|
169
|
+
// does not abort the others). Callers pass the exact link set: ALL github links on a fresh
|
|
170
|
+
// post, or just the reconcile-confirmed-missing subset on a reclaim (so a link that already
|
|
171
|
+
// has the comment is never double-posted).
|
|
172
|
+
async function postThrough(
|
|
173
|
+
kind: TaskCommentKind,
|
|
174
|
+
payload: TaskCommentPayload,
|
|
175
|
+
body: string,
|
|
176
|
+
links: TaskIssueLink[],
|
|
177
|
+
opts: WeaveOptions,
|
|
178
|
+
): Promise<WriteThroughResult> {
|
|
179
|
+
assertWriteThroughEligible(kind);
|
|
180
|
+
const formatted = formatTaskComment(payload, body);
|
|
181
|
+
const result: WriteThroughResult = { posted: [], failed: [] };
|
|
182
|
+
for (const link of links) {
|
|
183
|
+
if (link.provider !== "github") continue;
|
|
184
|
+
const ref = getIssueRef(link.issueRefId);
|
|
185
|
+
if (!ref) continue;
|
|
186
|
+
try {
|
|
187
|
+
await adapterFor(ref, opts).comment(ref, formatted);
|
|
188
|
+
result.posted.push(link);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
const message = errMessage(e);
|
|
191
|
+
result.failed.push({ link, error: message });
|
|
192
|
+
if (opts.log) opts.log(`task-issue weave: comment-through failed for ${link.repo}#${link.externalKey}: ${message}`);
|
|
193
|
+
else console.warn(`task-issue weave: comment-through failed for ${link.repo}#${link.externalKey}: ${message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface ReconcileResult {
|
|
200
|
+
// Links that ALREADY carry a relay marker for this (taskId, kind, key) — the post landed.
|
|
201
|
+
matched: TaskIssueLink[];
|
|
202
|
+
// Links read successfully with NO marker — the post is genuinely missing and must be sent.
|
|
203
|
+
missing: TaskIssueLink[];
|
|
204
|
+
// Links whose comments could not be read — state UNKNOWN; we must neither finalize nor post.
|
|
205
|
+
unreadable: TaskIssueLink[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Per-link reconcile of a reclaimed-stale reservation (#833): for each linked GitHub issue,
|
|
209
|
+
// classify it as already-has-the-marker (matched), readable-but-missing (must post), or
|
|
210
|
+
// unreadable (unknown). The caller finalizes ONLY when every link is in a known state — a
|
|
211
|
+
// single unreadable link blocks finalization so a Task with multiple issues can never clear
|
|
212
|
+
// its retry slot while one issue's state is still unknown.
|
|
213
|
+
async function reconcileExistingPost(
|
|
214
|
+
taskId: number,
|
|
215
|
+
kind: TaskCommentKind,
|
|
216
|
+
slotKey: string,
|
|
217
|
+
links: TaskIssueLink[],
|
|
218
|
+
opts: WeaveOptions,
|
|
219
|
+
): Promise<ReconcileResult> {
|
|
220
|
+
const matched: TaskIssueLink[] = [];
|
|
221
|
+
const missing: TaskIssueLink[] = [];
|
|
222
|
+
const unreadable: TaskIssueLink[] = [];
|
|
223
|
+
for (const link of links) {
|
|
224
|
+
if (link.provider !== "github") continue;
|
|
225
|
+
const ref = getIssueRef(link.issueRefId);
|
|
226
|
+
if (!ref) continue;
|
|
227
|
+
const adapter = adapterFor(ref, opts);
|
|
228
|
+
if (!adapter.listComments) { unreadable.push(link); continue; }
|
|
229
|
+
try {
|
|
230
|
+
const comments = await adapter.listComments(ref);
|
|
231
|
+
const found = comments.some((c) => {
|
|
232
|
+
const p = parseTaskComment(c);
|
|
233
|
+
return p !== null && p.taskId === taskId && p.kind === kind && p.key === slotKey;
|
|
234
|
+
});
|
|
235
|
+
(found ? matched : missing).push(link);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
unreadable.push(link);
|
|
238
|
+
if (opts.log) opts.log(`task-issue weave: reconcile read failed for ${link.repo}#${link.externalKey}: ${errMessage(e)}`);
|
|
239
|
+
else console.warn(`task-issue weave: reconcile read failed for ${link.repo}#${link.externalKey}: ${errMessage(e)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { matched, missing, unreadable };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Shared reclaim handler (discussion + milestone are structurally identical here). Returns the
|
|
246
|
+
// finished result, or throws CommentThroughError to leave the reservation durable. Only called
|
|
247
|
+
// when a stale reservation was reclaimed AND there are GitHub links to reconcile.
|
|
248
|
+
async function reconcileReclaim(params: {
|
|
249
|
+
taskId: number;
|
|
250
|
+
kind: TaskCommentKind;
|
|
251
|
+
slotKey: string;
|
|
252
|
+
payload: TaskCommentPayload;
|
|
253
|
+
body: string;
|
|
254
|
+
githubLinks: TaskIssueLink[];
|
|
255
|
+
reservation: TaskCommentThroughRecord;
|
|
256
|
+
recordReceipt: (posted: TaskIssueLink[], failed: Array<{ link: TaskIssueLink; error: string }>) => TaskEvent;
|
|
257
|
+
opts: WeaveOptions;
|
|
258
|
+
}): Promise<WeaveWriteResult> {
|
|
259
|
+
const { taskId, kind, slotKey, payload, body, githubLinks, reservation, recordReceipt, opts } = params;
|
|
260
|
+
const recon = await reconcileExistingPost(taskId, kind, slotKey, githubLinks, opts);
|
|
261
|
+
|
|
262
|
+
// ANY unreadable link → state unknown → do not finalize, do not blind-post. Leave the
|
|
263
|
+
// reservation `reserved` (durable) and surface; a later retry reconciles again.
|
|
264
|
+
if (recon.unreadable.length > 0) {
|
|
265
|
+
throw new CommentThroughError(
|
|
266
|
+
`comment-through reconcile could not read ${recon.unreadable.map((l) => `${l.repo}#${l.externalKey}`).join(", ")}; not finalizing/re-posting to avoid a duplicate`,
|
|
267
|
+
[],
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Every link readable. Post only the genuinely-missing ones (matched links already have it).
|
|
272
|
+
let posted = recon.matched;
|
|
273
|
+
if (recon.missing.length > 0) {
|
|
274
|
+
const result = await postThrough(kind, payload, body, recon.missing, opts);
|
|
275
|
+
if (result.failed.length > 0) {
|
|
276
|
+
// A missing link still couldn't be posted → not every link is satisfied. Keep the slot
|
|
277
|
+
// reclaimable and surface, rather than finalize with a hole.
|
|
278
|
+
failCommentThrough(reservation.id, Date.now());
|
|
279
|
+
throw new CommentThroughError(
|
|
280
|
+
`comment-through reconcile failed to post missing issue(s): ${result.failed.map((f) => `${f.link.repo}#${f.link.externalKey}: ${f.error}`).join("; ")}`,
|
|
281
|
+
result.failed,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
posted = [...recon.matched, ...result.posted];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Every link now satisfied (already-present + freshly-posted) → finalize exactly once.
|
|
288
|
+
const event = recordReceipt(posted, []);
|
|
289
|
+
finalizeCommentThrough(reservation.id, event.id, Date.now());
|
|
290
|
+
emitChanged(taskId);
|
|
291
|
+
return { taskId, posted, failed: [], event, deduped: recon.missing.length === 0 };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface WeaveWriteResult {
|
|
295
|
+
taskId: number;
|
|
296
|
+
posted: TaskIssueLink[];
|
|
297
|
+
failed: Array<{ link: TaskIssueLink; error: string }>;
|
|
298
|
+
event: TaskEvent | null;
|
|
299
|
+
deduped: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function emitChanged(taskId: number): void {
|
|
303
|
+
const detail = getTaskDetail(taskId);
|
|
304
|
+
if (detail) emitTaskChanged(detail, "task.updated");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- Comment-on-Task → write-through -------------------------------------------------
|
|
308
|
+
export interface CommentOnTaskInput {
|
|
309
|
+
taskId: number;
|
|
310
|
+
body: string;
|
|
311
|
+
author?: string;
|
|
312
|
+
/** Optional idempotency key: a re-delivered comment with the same key posts once. */
|
|
313
|
+
clientId?: string;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface CommentOnTaskResult {
|
|
317
|
+
taskId: number;
|
|
318
|
+
posted: TaskIssueLink[];
|
|
319
|
+
failed: Array<{ link: TaskIssueLink; error: string }>;
|
|
320
|
+
event: TaskEvent | null;
|
|
321
|
+
deduped: boolean;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Thrown when every GitHub write-through failed. NOT a ValidationError — the request was
|
|
325
|
+
// well-formed; GitHub was unreachable — so it must NOT leave a posted receipt (finding 1):
|
|
326
|
+
// a clientId retry re-hits GitHub because the reservation was flipped to 'failed'.
|
|
327
|
+
export class CommentThroughError extends Error {
|
|
328
|
+
constructor(message: string, readonly failures: Array<{ link: TaskIssueLink; error: string }>) {
|
|
329
|
+
super(message);
|
|
330
|
+
this.name = "CommentThroughError";
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function commentOnTaskThrough(input: CommentOnTaskInput, opts: WeaveOptions = {}): Promise<CommentOnTaskResult> {
|
|
335
|
+
const taskId = input.taskId;
|
|
336
|
+
const detail = getTaskDetail(taskId);
|
|
337
|
+
if (!detail) throw new ValidationError(`task ${taskId} not found`);
|
|
338
|
+
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
339
|
+
if (!body) throw new ValidationError("comment body required");
|
|
340
|
+
|
|
341
|
+
// Discussion has no relay backing store — it lives in GitHub. With no GitHub-backed
|
|
342
|
+
// issue linked, there is nowhere for it to live, so the write-through is rejected rather
|
|
343
|
+
// than silently dropped into a table that would drift.
|
|
344
|
+
const githubLinks = detail.issues.filter((link) => link.provider === "github");
|
|
345
|
+
if (githubLinks.length === 0) {
|
|
346
|
+
throw new ValidationError("task has no linked GitHub issue to write the comment through to");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const clientId = input.clientId?.trim();
|
|
350
|
+
const author = input.author?.trim() || "agent-relay";
|
|
351
|
+
const payload: TaskCommentPayload = { v: TASK_COMMENT_FORMAT_VERSION, kind: "discussion", taskId, author, ...(clientId ? { key: clientId } : {}) };
|
|
352
|
+
|
|
353
|
+
// The success-scoped receipt writer (shared by the normal-post and reconcile-found paths):
|
|
354
|
+
// an AUDIT of what actually landed on GitHub (posted only), not a second store of the
|
|
355
|
+
// discussion. GitHub stays canonical; the Task renders by reading it back.
|
|
356
|
+
const recordReceipt = (posted: TaskIssueLink[], failed: Array<{ link: TaskIssueLink; error: string }>): TaskEvent =>
|
|
357
|
+
insertTaskEvent(taskId, {
|
|
358
|
+
source: author,
|
|
359
|
+
type: "comment.posted",
|
|
360
|
+
severity: "info",
|
|
361
|
+
title: `Comment written through to ${posted.map((l) => `${l.repo}#${l.externalKey}`).join(", ")}`,
|
|
362
|
+
body,
|
|
363
|
+
metadata: {
|
|
364
|
+
kind: "discussion",
|
|
365
|
+
author,
|
|
366
|
+
...(clientId ? { clientId } : {}),
|
|
367
|
+
posted: posted.map((l) => ({ issueRefId: l.issueRefId, repo: l.repo, externalKey: l.externalKey, url: l.url })),
|
|
368
|
+
...(failed.length > 0 ? { failed: failed.map((f) => ({ issueRefId: f.link.issueRefId, error: f.error })) } : {}),
|
|
369
|
+
},
|
|
370
|
+
}, Date.now());
|
|
371
|
+
|
|
372
|
+
// Idempotency = reserve→post→finalize (NOT scan→post→insert): the (taskId,kind,clientId)
|
|
373
|
+
// slot is claimed TRANSACTIONALLY before the awaited GitHub append, so concurrent same-key
|
|
374
|
+
// requests can't both post, and a crash leaves a durable reservation rather than re-posting.
|
|
375
|
+
let reservation: TaskCommentThroughRecord | undefined;
|
|
376
|
+
if (clientId) {
|
|
377
|
+
const reserved = reserveCommentThrough(taskId, "discussion", clientId, Date.now());
|
|
378
|
+
if (reserved.outcome === "duplicate") {
|
|
379
|
+
return { taskId, posted: [], failed: [], event: reserved.record.eventId ? getTaskEvent(reserved.record.eventId) : null, deduped: true };
|
|
380
|
+
}
|
|
381
|
+
if (reserved.outcome === "in_flight") {
|
|
382
|
+
return { taskId, posted: [], failed: [], event: null, deduped: true };
|
|
383
|
+
}
|
|
384
|
+
reservation = reserved.record;
|
|
385
|
+
// Stale-`reserved` reclaim is ambiguous (post-before-finalize crash). Reconcile each linked
|
|
386
|
+
// issue against GitHub before re-posting: finalize only when EVERY link's state is known
|
|
387
|
+
// (already-posted or freshly-posted); a single unreadable link leaves the slot durable.
|
|
388
|
+
if (reserved.reconcileNeeded) {
|
|
389
|
+
return await reconcileReclaim({ taskId, kind: "discussion", slotKey: clientId, payload, body, githubLinks, reservation, recordReceipt, opts });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const result = await postThrough("discussion", payload, body, githubLinks, opts);
|
|
394
|
+
|
|
395
|
+
// Finding 1: zero successful GitHub posts → NO receipt. Release the reservation to 'failed'
|
|
396
|
+
// (reclaimable) and surface the failure so a retry actually re-hits GitHub. The Task never
|
|
397
|
+
// shows a comment as posted when GitHub never received it.
|
|
398
|
+
if (result.posted.length === 0) {
|
|
399
|
+
if (reservation) failCommentThrough(reservation.id, Date.now());
|
|
400
|
+
throw new CommentThroughError(
|
|
401
|
+
`comment write-through failed for all linked issue(s): ${result.failed.map((f) => `${f.link.repo}#${f.link.externalKey}: ${f.error}`).join("; ")}`,
|
|
402
|
+
result.failed,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const event = recordReceipt(result.posted, result.failed);
|
|
407
|
+
if (reservation) finalizeCommentThrough(reservation.id, event.id, Date.now());
|
|
408
|
+
emitChanged(taskId);
|
|
409
|
+
return { taskId, posted: result.posted, failed: result.failed, event, deduped: false };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// --- Milestone summary (the single-comment exception) — SEAM for #831 ----------------
|
|
413
|
+
export interface MilestoneCommentInput {
|
|
414
|
+
taskId: number;
|
|
415
|
+
milestone: string;
|
|
416
|
+
summary: string;
|
|
417
|
+
/** One comment per boundary: e.g. the land key, so #831 re-firing posts once. */
|
|
418
|
+
idempotencyKey?: string;
|
|
419
|
+
commitSha?: string;
|
|
420
|
+
gates?: TaskGateState[];
|
|
421
|
+
author?: string;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export interface MilestoneCommentResult {
|
|
425
|
+
taskId: number;
|
|
426
|
+
posted: TaskIssueLink[];
|
|
427
|
+
failed: Array<{ link: TaskIssueLink; error: string }>;
|
|
428
|
+
event: TaskEvent | null;
|
|
429
|
+
deduped: boolean;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function gatesLine(gates: TaskGateState[] | undefined): string {
|
|
433
|
+
if (!gates || gates.length === 0) return "";
|
|
434
|
+
return `\ngates: ${gates.map((g) => `${g.gate} ${g.status === "pass" ? "✓" : "✗"}`).join(" · ")}`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The MECHANISM for the milestone exception. #831's land trigger calls this once at a
|
|
438
|
+
// boundary (land/deploy). Idempotent by (milestone, idempotencyKey) so a retried land
|
|
439
|
+
// surfaces exactly one structured summary. NOT wired into the land pipeline here.
|
|
440
|
+
export async function postTaskMilestoneComment(input: MilestoneCommentInput, opts: WeaveOptions = {}): Promise<MilestoneCommentResult> {
|
|
441
|
+
const taskId = input.taskId;
|
|
442
|
+
const detail = getTaskDetail(taskId);
|
|
443
|
+
if (!detail) throw new ValidationError(`task ${taskId} not found`);
|
|
444
|
+
const milestone = input.milestone?.trim();
|
|
445
|
+
if (!milestone) throw new ValidationError("milestone required");
|
|
446
|
+
const summary = input.summary?.trim();
|
|
447
|
+
if (!summary) throw new ValidationError("milestone summary required");
|
|
448
|
+
|
|
449
|
+
// The milestone slot is the (milestone, idempotencyKey) pair so distinct boundaries don't
|
|
450
|
+
// collide. The marker embeds that same slotKey so a reclaim can reconcile against GitHub.
|
|
451
|
+
const idempotencyKey = input.idempotencyKey?.trim();
|
|
452
|
+
const slotKey = idempotencyKey ? `${milestone}:${idempotencyKey}` : undefined;
|
|
453
|
+
const author = input.author?.trim() || "agent-relay";
|
|
454
|
+
const body = `${summary}${input.commitSha ? `\ncommit: \`${input.commitSha.slice(0, 12)}\`` : ""}${gatesLine(input.gates)}`;
|
|
455
|
+
const payload: TaskCommentPayload = { v: TASK_COMMENT_FORMAT_VERSION, kind: "milestone", taskId, author, milestone, ...(slotKey ? { key: slotKey } : {}) };
|
|
456
|
+
|
|
457
|
+
const recordReceipt = (posted: TaskIssueLink[], failed: Array<{ link: TaskIssueLink; error: string }>): TaskEvent =>
|
|
458
|
+
insertTaskEvent(taskId, {
|
|
459
|
+
source: author,
|
|
460
|
+
type: "milestone.posted",
|
|
461
|
+
severity: "info",
|
|
462
|
+
title: `Milestone ${milestone}`,
|
|
463
|
+
body: summary,
|
|
464
|
+
metadata: {
|
|
465
|
+
kind: "milestone",
|
|
466
|
+
milestone,
|
|
467
|
+
author,
|
|
468
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
469
|
+
...(input.commitSha ? { commitSha: input.commitSha } : {}),
|
|
470
|
+
posted: posted.map((l) => ({ issueRefId: l.issueRefId, repo: l.repo, externalKey: l.externalKey, url: l.url })),
|
|
471
|
+
...(failed.length > 0 ? { failed: failed.map((f) => ({ issueRefId: f.link.issueRefId, error: f.error })) } : {}),
|
|
472
|
+
},
|
|
473
|
+
}, Date.now());
|
|
474
|
+
|
|
475
|
+
// Same reserve→post→finalize + reconcile-on-reclaim discipline as discussion, so a re-fired
|
|
476
|
+
// land posts at most one summary even across concurrency and post-before-finalize crashes.
|
|
477
|
+
const githubLinks = detail.issues.filter((link) => link.provider === "github");
|
|
478
|
+
let reservation: TaskCommentThroughRecord | undefined;
|
|
479
|
+
if (slotKey) {
|
|
480
|
+
const reserved = reserveCommentThrough(taskId, "milestone", slotKey, Date.now());
|
|
481
|
+
if (reserved.outcome === "duplicate") {
|
|
482
|
+
return { taskId, posted: [], failed: [], event: reserved.record.eventId ? getTaskEvent(reserved.record.eventId) : null, deduped: true };
|
|
483
|
+
}
|
|
484
|
+
if (reserved.outcome === "in_flight") {
|
|
485
|
+
return { taskId, posted: [], failed: [], event: null, deduped: true };
|
|
486
|
+
}
|
|
487
|
+
reservation = reserved.record;
|
|
488
|
+
if (reserved.reconcileNeeded && githubLinks.length > 0 && slotKey) {
|
|
489
|
+
return await reconcileReclaim({ taskId, kind: "milestone", slotKey, payload, body, githubLinks, reservation, recordReceipt, opts });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// No GitHub-backed issue → nothing to post through, but still leave a local timeline marker.
|
|
494
|
+
// When there ARE linked issues and EVERY post fails, treat it like a failed write-through
|
|
495
|
+
// (finding 1): release the reservation and surface the failure so #831 can re-fire.
|
|
496
|
+
const result = githubLinks.length > 0
|
|
497
|
+
? await postThrough("milestone", payload, body, githubLinks, opts)
|
|
498
|
+
: { posted: [] as TaskIssueLink[], failed: [] as Array<{ link: TaskIssueLink; error: string }> };
|
|
499
|
+
if (githubLinks.length > 0 && result.posted.length === 0) {
|
|
500
|
+
if (reservation) failCommentThrough(reservation.id, Date.now());
|
|
501
|
+
throw new CommentThroughError(
|
|
502
|
+
`milestone write-through failed for all linked issue(s): ${result.failed.map((f) => `${f.link.repo}#${f.link.externalKey}: ${f.error}`).join("; ")}`,
|
|
503
|
+
result.failed,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const event = recordReceipt(result.posted, result.failed);
|
|
508
|
+
if (reservation) finalizeCommentThrough(reservation.id, event.id, Date.now());
|
|
509
|
+
emitChanged(taskId);
|
|
510
|
+
return { taskId, posted: result.posted, failed: result.failed, event, deduped: false };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// --- Reads ---------------------------------------------------------------------------
|
|
514
|
+
export function listWriteThroughEvents(taskId: number): TaskEvent[] {
|
|
515
|
+
return listTaskEvents(taskId).filter((e) => (WRITE_THROUGH_EVENT_TYPES as readonly string[]).includes(e.type));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// --- Close-bridge projection (#482/#474 receipt, not a copy) --------------------------
|
|
519
|
+
export interface TaskCloseBridgeEntry {
|
|
520
|
+
issueRefId: string;
|
|
521
|
+
provider: string;
|
|
522
|
+
repo: string;
|
|
523
|
+
externalKey: string;
|
|
524
|
+
url: string;
|
|
525
|
+
issueStatus: string;
|
|
526
|
+
/** A successful #482/#474 close action exists for this issue (the canonical record). */
|
|
527
|
+
closed: boolean;
|
|
528
|
+
closedAt?: number;
|
|
529
|
+
commitSha?: string;
|
|
530
|
+
landKey?: string;
|
|
531
|
+
source?: string;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Projects the canonical issue-lifecycle ledger onto the Task's linked issues. This is the
|
|
535
|
+
// answer to "did #482 already close it?" — read, never duplicated. The land WIRING that
|
|
536
|
+
// advances the Task on land is #831; this projection is always accurate regardless, because
|
|
537
|
+
// #474 writes the ledger row the moment it closes the issue.
|
|
538
|
+
export function taskCloseBridge(taskId: number): TaskCloseBridgeEntry[] {
|
|
539
|
+
const detail = getTaskDetail(taskId);
|
|
540
|
+
if (!detail) throw new ValidationError(`task ${taskId} not found`);
|
|
541
|
+
return detail.issues.map((link) => {
|
|
542
|
+
const actions = listSuccessfulCloseActionsForIssueRef(link.issueRefId);
|
|
543
|
+
const latest = actions[0];
|
|
544
|
+
return {
|
|
545
|
+
issueRefId: link.issueRefId,
|
|
546
|
+
provider: link.provider,
|
|
547
|
+
repo: link.repo,
|
|
548
|
+
externalKey: link.externalKey,
|
|
549
|
+
url: link.url,
|
|
550
|
+
issueStatus: link.status,
|
|
551
|
+
closed: Boolean(latest),
|
|
552
|
+
...(latest?.updatedAt ? { closedAt: latest.updatedAt } : {}),
|
|
553
|
+
...(latest?.commitSha ? { commitSha: latest.commitSha } : {}),
|
|
554
|
+
...(latest?.landKey ? { landKey: latest.landKey } : {}),
|
|
555
|
+
...(latest?.source ? { source: latest.source } : {}),
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
}
|