Skip to content

Commit 2118494

Browse files
Move run completion summary from Linear to the cockpit
The per-run completion/blocked summary (outcome, AC evidence, debug bundle link) was posted as a Linear comment on every run. Move it to the cockpit so Linear stays a clean control plane: state transitions only, with the "why" living in the cockpit ticket detail. - New Cockpit.RunSummaryStore: one markdown per issue under /opt/symphony/state/run-summaries (SYMPHONY_COCKPIT_SUMMARY_DIR), separate root from the QA evidence store so the two completion-time writers never race. - CompletionSummary.publish writes to the store instead of Tracker; build_comment drops the debug-bundle plumbing. publish_async signature unchanged, so the WorkpadPrSync caller is untouched. - DebugBundle deleted: CompletionSummary was its only caller, and the bundle only existed to attach to the Linear comment. Removed rather than left dead. - Board reads each issue's summary (local disk, bounded by the board cache) and exposes it as ticket.summary; cockpit detail renders a "Run summary" section. - Test stores point at tmp via test_helper so the suite never writes to /opt/symphony; integration tests assert routing, summary content is unit-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2692c3d commit 2118494

17 files changed

Lines changed: 282 additions & 568 deletions

File tree

dashboard/src/features/board/components/ticket-detail.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ describe("TicketDetail", () => {
3030
expect(screen.getByText(/no loading flicker \| PASS/)).toBeInTheDocument();
3131
});
3232

33+
it("renders the run summary when present", async () => {
34+
render(<TicketDetail ticket={running} onClose={() => {}} />);
35+
await screen.findByText("Timeline");
36+
expect(screen.getByText("Run summary")).toBeInTheDocument();
37+
expect(
38+
screen.getByText(/Moved to `In Code Review` after PR checks and Symphony gates passed/)
39+
).toBeInTheDocument();
40+
});
41+
3342
it("exposes Linear, GitHub PR and Langfuse trace as new-tab links", async () => {
3443
render(<TicketDetail ticket={running} onClose={() => {}} />);
3544
await screen.findByText("Timeline");

dashboard/src/features/board/components/ticket-detail.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,19 +141,32 @@ function Body({ ticket }: { ticket: Ticket }) {
141141
</section>
142142
</div>
143143

144+
{ticket.summary && (
145+
<section>
146+
<SectionLabel>Run summary</SectionLabel>
147+
<MarkdownBlock>{ticket.summary}</MarkdownBlock>
148+
</section>
149+
)}
150+
144151
{ticket.report && (
145152
<section>
146153
<SectionLabel>QA report</SectionLabel>
147-
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
148-
{ticket.report.trim()}
149-
</pre>
154+
<MarkdownBlock>{ticket.report}</MarkdownBlock>
150155
</section>
151156
)}
152157
</div>
153158
</>
154159
);
155160
}
156161

162+
function MarkdownBlock({ children }: { children: string }) {
163+
return (
164+
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
165+
{children.trim()}
166+
</pre>
167+
);
168+
}
169+
157170
function SectionLabel({ children }: { children: React.ReactNode }) {
158171
return (
159172
<h3 className="mb-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">

dashboard/src/features/board/contract.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const Ticket = z.object({
4646
pr: Pr.nullable(),
4747
evidence: z.array(Evidence),
4848
report: z.string().nullable().optional(), // QA self-review report (markdown), from the evidence store
49+
summary: z.string().nullable().optional(), // run completion summary (markdown), from the run summary store
4950
timeline: z.array(TimelineStep).optional(),
5051
url: z.string().url().nullable().optional(), // Linear issue link
5152
traceUrl: z.string().url().nullable().optional(), // Langfuse trace link

dashboard/src/features/board/fixtures.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export const MOCK_BOARD: BoardPayload = {
5151
],
5252
report:
5353
"- Result: PASS\n\n| Check | Result |\n| --- | --- |\n| debounce fires one request | PASS |\n| no loading flicker | PASS |\n",
54+
summary:
55+
"## Ready for review\n\n**Outcome:** Moved to `In Code Review` after PR checks and Symphony gates passed.\n**PR:** https://github.com/schoolsoutapp/fe-next-app/pull/642\n",
5456
timeline: [
5557
{ label: "Read issue and extracted acceptance criteria", turn: 1, status: "done" },
5658
{ label: "Implemented debounce in SearchInput", turn: 2, status: "done" },

elixir/lib/symphony_elixir/cockpit/api.ex

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule SymphonyElixir.Cockpit.Api do
1111

1212
use Plug.Router
1313

14-
alias SymphonyElixir.Cockpit.{BoardCache, BoardView, Checks, EvidenceStore}
14+
alias SymphonyElixir.Cockpit.{BoardCache, BoardView, Checks, EvidenceStore, RunSummaryStore}
1515
alias SymphonyElixir.Config
1616
alias SymphonyElixir.RunLedger.Report
1717
alias SymphonyElixir.Tracker
@@ -55,7 +55,8 @@ defmodule SymphonyElixir.Cockpit.Api do
5555
BoardView.assemble(issues, read_runs(), tracker,
5656
running: read_running(),
5757
ci: ci_map(issues),
58-
evidence: evidence_map(issues)
58+
evidence: evidence_map(issues),
59+
summary: summary_map(issues)
5960
)
6061
end
6162

@@ -65,6 +66,11 @@ defmodule SymphonyElixir.Cockpit.Api do
6566
Map.new(issues, fn issue -> {issue.id, EvidenceStore.read(issue.id)} end)
6667
end
6768

69+
# Completion summary markdown per issue, read from the local cockpit store.
70+
defp summary_map(issues) do
71+
Map.new(issues, fn issue -> {issue.id, RunSummaryStore.read(issue.id)} end)
72+
end
73+
6874
# CI status per PR url, fetched once per board build (bounded by BoardCache).
6975
defp ci_map(issues) do
7076
issues

elixir/lib/symphony_elixir/cockpit/board_view.ex

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ defmodule SymphonyElixir.Cockpit.BoardView do
4949
ci = Map.new(Keyword.get(opts, :ci, []))
5050
# QA evidence manifests keyed by internal issue id (cockpit evidence store).
5151
evidence = Map.new(Keyword.get(opts, :evidence, []))
52+
# Completion summary markdown keyed by internal issue id (run summary store).
53+
summary = Map.new(Keyword.get(opts, :summary, []))
5254

5355
%{
5456
"states" => %{
@@ -60,11 +62,11 @@ defmodule SymphonyElixir.Cockpit.BoardView do
6062
"onReject" => tracker.on_reject_state,
6163
"terminal" => list(tracker.terminal_states)
6264
},
63-
"tickets" => Enum.map(issues, &ticket(&1, runs_by_ticket, trace_base, running, ci, evidence))
65+
"tickets" => Enum.map(issues, &ticket(&1, runs_by_ticket, trace_base, running, ci, evidence, summary))
6466
}
6567
end
6668

67-
defp ticket(%Issue{} = issue, runs_by_ticket, trace_base, running, ci, evidence) do
69+
defp ticket(%Issue{} = issue, runs_by_ticket, trace_base, running, ci, evidence, summary) do
6870
run = Map.get(runs_by_ticket, issue.identifier)
6971
status = if MapSet.member?(running, issue.id), do: "running", else: "idle"
7072
manifest = Map.get(evidence, issue.id, %{})
@@ -83,6 +85,7 @@ defmodule SymphonyElixir.Cockpit.BoardView do
8385
"pr" => pr(issue, ci),
8486
"evidence" => evidence_items(issue.id, manifest),
8587
"report" => Map.get(manifest, "report"),
88+
"summary" => Map.get(summary, issue.id),
8689
"url" => issue.url,
8790
"traceUrl" => trace_url(run, trace_base),
8891
"updatedAt" => issue.updated_at || ""
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
defmodule SymphonyElixir.Cockpit.RunSummaryStore do
2+
@moduledoc """
3+
On-box store for a run's completion summary, shown in the cockpit ticket
4+
detail instead of posted as a tracker comment. One markdown file per issue
5+
(keyed by the orchestrator's internal issue id), overwritten each completion.
6+
7+
Root is `SYMPHONY_COCKPIT_SUMMARY_DIR` (default
8+
`/opt/symphony/state/run-summaries`). Deliberately separate from the QA
9+
evidence store: both writers fire at completion time as concurrent tasks, so
10+
keeping them in different directories means they never race on the same path.
11+
"""
12+
13+
require Logger
14+
15+
@default_dir "/opt/symphony/state/run-summaries"
16+
17+
@spec dir() :: String.t()
18+
def dir, do: System.get_env("SYMPHONY_COCKPIT_SUMMARY_DIR") || @default_dir
19+
20+
@doc "Write (overwrite) the per-issue completion summary markdown."
21+
@spec put(String.t(), String.t()) :: :ok
22+
def put(issue_id, markdown) when is_binary(issue_id) and is_binary(markdown) do
23+
File.mkdir_p!(dir())
24+
File.write!(file(issue_id), markdown)
25+
:ok
26+
end
27+
28+
@doc "The per-issue completion summary markdown, or `nil` when none is stored."
29+
@spec read(String.t()) :: String.t() | nil
30+
def read(issue_id) when is_binary(issue_id) do
31+
case File.read(file(issue_id)) do
32+
{:ok, content} -> content
33+
_ -> nil
34+
end
35+
end
36+
37+
defp file(issue_id), do: Path.join(dir(), sanitize(issue_id) <> ".md")
38+
39+
defp sanitize(issue_id), do: String.replace(issue_id, ~r/[^A-Za-z0-9._-]/, "_")
40+
end

elixir/lib/symphony_elixir/orchestrator/completion_summary.ex

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
defmodule SymphonyElixir.Orchestrator.CompletionSummary do
22
@moduledoc """
3-
Posts the single human-facing completion/blocked comment for a run.
3+
Writes the single human-facing completion/blocked summary for a run.
44
5-
Detailed internal artifacts are uploaded as one debug zip and linked from
6-
this comment instead of being scattered through the Linear thread.
5+
The summary used to be a tracker comment; it now goes to the cockpit run
6+
summary store (`SymphonyElixir.Cockpit.RunSummaryStore`) and surfaces in the
7+
cockpit ticket detail, keeping the tracker a clean control plane. One markdown
8+
per issue, overwritten each completion.
79
"""
810

911
require Logger
1012

11-
alias SymphonyElixir.Orchestrator.DebugBundle
12-
alias SymphonyElixir.Tracker
13+
alias SymphonyElixir.Cockpit.RunSummaryStore
1314

1415
@max_ac_evidence_chars 3_000
1516

@@ -23,38 +24,35 @@ defmodule SymphonyElixir.Orchestrator.CompletionSummary do
2324
:ok
2425
end
2526

27+
# `parent_comment_id` is a tracker-thread leftover, now ignored — the summary
28+
# no longer posts to Linear.
2629
@doc false
2730
@spec publish(map(), String.t() | nil, map(), term(), String.t() | nil) :: :ok
28-
def publish(issue, target_state, running_entry, reason, parent_comment_id) do
29-
bundle_result = DebugBundle.create_and_upload(issue, running_entry, reason)
30-
body = build_comment(issue, target_state, running_entry, reason, bundle_result)
31-
issue_id = Map.get(issue, :id)
32-
opts = if is_binary(parent_comment_id), do: [parent_id: parent_comment_id], else: []
33-
34-
case Tracker.create_comment(issue_id, body, opts) do
35-
{:ok, comment_id} ->
36-
Logger.info("Completion summary posted issue=#{identifier(issue, running_entry)} comment=#{comment_id}")
37-
38-
{:error, reason} ->
39-
Logger.warning("Completion summary failed issue=#{identifier(issue, running_entry)} reason=#{inspect(reason)}")
31+
def publish(issue, target_state, running_entry, reason, _parent_comment_id) do
32+
body = build_comment(issue, target_state, running_entry, reason)
33+
34+
case Map.get(issue, :id) do
35+
issue_id when is_binary(issue_id) ->
36+
RunSummaryStore.put(issue_id, body)
37+
Logger.info("Completion summary stored issue=#{identifier(issue, running_entry)}")
38+
39+
_ ->
40+
Logger.warning("Completion summary skipped (missing issue id) issue=#{identifier(issue, running_entry)}")
4041
end
4142

4243
:ok
4344
end
4445

4546
@doc false
46-
@spec build_comment(map(), String.t() | nil, map(), term(), {:ok, String.t()} | {:error, term()}) :: String.t()
47-
def build_comment(issue, target_state, running_entry, reason, bundle_result) do
47+
@spec build_comment(map(), String.t() | nil, map(), term()) :: String.t()
48+
def build_comment(issue, target_state, running_entry, reason) do
4849
[
4950
heading(reason),
5051
"",
5152
"**Outcome:** #{outcome(reason, target_state)}",
5253
pr_line(issue),
5354
ac_evidence_block(running_entry),
54-
qa_line(reason),
55-
debug_bundle_line(bundle_result),
56-
"",
57-
"_Posted by Symphony. Detailed agent artifacts are in the debug bundle._"
55+
qa_line(reason)
5856
]
5957
|> Enum.reject(&is_nil/1)
6058
|> Enum.join("\n")
@@ -123,22 +121,19 @@ defmodule SymphonyElixir.Orchestrator.CompletionSummary do
123121
|> String.trim()
124122
|> String.slice(0, @max_ac_evidence_chars)
125123

126-
suffix = if String.length(text) > @max_ac_evidence_chars, do: "\n\n_(truncated; full text in debug bundle)_", else: ""
124+
suffix = if String.length(text) > @max_ac_evidence_chars, do: "\n\n_(truncated)_", else: ""
127125
"### AC evidence\n\n#{trimmed}#{suffix}"
128126
else
129-
"**AC evidence:** not captured in the final agent text; see debug bundle for the last message."
127+
"**AC evidence:** not captured in the final agent text."
130128
end
131129
end
132130

133131
defp qa_line(:qa_artifact_missing), do: "**QA evidence:** missing; visual QA was required."
134132

135133
defp qa_line(_reason) do
136-
"**QA evidence:** screenshots/video/trace are published separately when present; raw files are also in the debug bundle."
134+
"**QA evidence:** screenshots/video are published to the cockpit when present."
137135
end
138136

139-
defp debug_bundle_line({:ok, url}), do: "**Debug bundle:** [agent-debug.zip](#{url})"
140-
defp debug_bundle_line({:error, reason}), do: "**Debug bundle:** unavailable (#{inspect(reason)})."
141-
142137
defp inline_list(items) when is_list(items) do
143138
Enum.map_join(items, ", ", &"`#{&1}`")
144139
end

0 commit comments

Comments
 (0)