diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index db72f0d16..110633cc0 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -15,7 +15,6 @@ import type { SignalReportSignalsResponse, SignalReportsQueryParams, SignalReportsResponse, - SignalReportTask, SuggestedReviewerWriteEntry, } from "./types"; @@ -152,28 +151,6 @@ export async function getAvailableSuggestedReviewers( return { results, count: results.length }; } -export async function getSignalReportTasks( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/tasks/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal report tasks", - ); - } - - const data = await response.json(); - return data.results ?? []; -} - export async function getSignalReportArtefacts( reportId: string, ): Promise { diff --git a/apps/mobile/src/features/inbox/types.ts b/apps/mobile/src/features/inbox/types.ts index 04afb0bff..33e1674fb 100644 --- a/apps/mobile/src/features/inbox/types.ts +++ b/apps/mobile/src/features/inbox/types.ts @@ -72,13 +72,6 @@ export interface AvailableSuggestedReviewersResponse { count: number; } -export interface SignalReportTask { - id: string; - relationship: string; - task_id: string; - created_at: string; -} - export interface Signal { signal_id: string; content: string; diff --git a/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts index 070f9f028..2aa343d4b 100644 --- a/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts +++ b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts @@ -22,6 +22,7 @@ describe("signed-commit tool handler", () => { createSignedCommit.mockReset(); createSignedCommit.mockResolvedValue({ branch: "posthog-code/feature", + repository: "x/y", commits: [ { sha: "deadbeef", url: "https://github.com/x/y/commit/deadbeef" }, ], diff --git a/packages/agent/src/adapters/signed-commit-shared.ts b/packages/agent/src/adapters/signed-commit-shared.ts index 782125563..0ed2a38a4 100644 --- a/packages/agent/src/adapters/signed-commit-shared.ts +++ b/packages/agent/src/adapters/signed-commit-shared.ts @@ -9,6 +9,7 @@ import { type SignedRewriteInput, } from "@posthog/git/signed-commit"; import { z } from "zod"; +import { reportCommitArtefacts } from "../signed-commit-artefacts"; import { qualifiedLocalToolName } from "./local-tools/registry"; /** @@ -167,7 +168,20 @@ export function runSignedCommitTool( ): Promise { return runSignedTool( SIGNED_COMMIT_TOOL_NAME, - createSignedCommit, + async (c, a: SignedCommitInput) => { + const result = await createSignedCommit(c, a); + // The "commit hook": every pushed commit becomes a `commit` artefact on the signal + // reports this task is associated with. Best-effort and awaited inside the tool's + // try/catch-free success path — reportCommitArtefacts never throws, so a failed + // artefact post can't fail a commit that already landed. git_signed_rewrite is + // intentionally not hooked (it republishes existing history). + await reportCommitArtefacts({ + taskId: c.taskId, + result, + message: a.message, + }); + return result; + }, (r) => `Created ${r.commits.length} signed commit(s) on ${r.branch}`, ctx, args, diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index fb0c161d1..fc8b9ccd5 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -245,6 +245,35 @@ export class PostHogAPIClient { return manifest.slice(-artifacts.length); } + /** Signal reports the given task is associated with (via report task associations). */ + async getSignalReportIdsForTask(taskId: string): Promise { + const teamId = this.getTeamId(); + const response = await this.apiRequest<{ results?: { id: string }[] }>( + `/api/projects/${teamId}/signals/reports/?task_id=${encodeURIComponent(taskId)}&limit=100`, + ); + return (response.results ?? []).map((r) => r.id); + } + + /** + * Append a log artefact to a signal report, attributed to `taskId` via the + * `X-PostHog-Task-Id` header (the server validates it against the token's team). + */ + async createSignalReportArtefact( + reportId: string, + taskId: string, + body: { artefact_type: string; content: Record }, + ): Promise { + const teamId = this.getTeamId(); + await this.apiRequest( + `/api/projects/${teamId}/signals/reports/${reportId}/artefacts/`, + { + method: "POST", + body: JSON.stringify(body), + headers: { "X-PostHog-Task-Id": taskId }, + }, + ); + } + /** * Download artifact content by storage path * Streams the file through the PostHog backend so the sandbox does not need diff --git a/packages/agent/src/signed-commit-artefacts.test.ts b/packages/agent/src/signed-commit-artefacts.test.ts new file mode 100644 index 000000000..e8a0f1942 --- /dev/null +++ b/packages/agent/src/signed-commit-artefacts.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { reportCommitArtefacts } from "./signed-commit-artefacts"; + +const ENV = { + POSTHOG_API_URL: "https://us.posthog.com", + POSTHOG_PERSONAL_API_KEY: "pha_test", + POSTHOG_PROJECT_ID: "7", +}; + +// Point the env-file read at a path that never exists so only `env` is used. +const NO_ENV_FILE = "/nonexistent/agent-env"; + +const RESULT = { + branch: "posthog-code/fix-foo", + repository: "posthog/posthog", + commits: [ + { sha: "aaa111", url: "https://github.com/posthog/posthog/commit/aaa111" }, + { sha: "bbb222", url: "https://github.com/posthog/posthog/commit/bbb222" }, + ], +}; + +describe("reportCommitArtefacts", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + it("posts one commit artefact per commit per associated report, attributed via header", async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + if (String(url).includes("/signals/reports/?")) { + return jsonResponse({ + results: [{ id: "report-1" }, { id: "report-2" }], + }); + } + return jsonResponse({ id: "artefact" }); + }); + + await reportCommitArtefacts({ + taskId: "task-1", + result: RESULT, + message: "fix: foo", + env: ENV, + envFilePath: NO_ENV_FILE, + }); + + const lookupCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes("/signals/reports/?task_id=task-1"), + ); + expect(lookupCalls).toHaveLength(1); + + const postCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes("/artefacts/"), + ); + // 2 commits × 2 reports. + expect(postCalls).toHaveLength(4); + for (const [url, init] of postCalls) { + expect(String(url)).toMatch( + /\/api\/projects\/7\/signals\/reports\/report-[12]\/artefacts\/$/, + ); + const headers = new Headers((init as RequestInit).headers); + expect(headers.get("X-PostHog-Task-Id")).toBe("task-1"); + const body = JSON.parse(String((init as RequestInit).body)); + expect(body.artefact_type).toBe("commit"); + expect(body.content.repository).toBe("posthog/posthog"); + expect(body.content.branch).toBe("posthog-code/fix-foo"); + expect(["aaa111", "bbb222"]).toContain(body.content.commit_sha); + expect(body.content.message).toBe("fix: foo"); + } + }); + + it("is a no-op without a task id", async () => { + await reportCommitArtefacts({ + taskId: undefined, + result: RESULT, + message: "fix: foo", + env: ENV, + envFilePath: NO_ENV_FILE, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("is a no-op without sandbox PostHog credentials", async () => { + await reportCommitArtefacts({ + taskId: "task-1", + result: RESULT, + message: "fix: foo", + env: {}, + envFilePath: NO_ENV_FILE, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("never throws when the report lookup fails", async () => { + fetchMock.mockRejectedValue(new Error("network down")); + await expect( + reportCommitArtefacts({ + taskId: "task-1", + result: RESULT, + message: "fix: foo", + env: ENV, + envFilePath: NO_ENV_FILE, + }), + ).resolves.toBeUndefined(); + }); + + it("keeps posting remaining artefacts when one post fails", async () => { + let postCount = 0; + fetchMock.mockImplementation(async (url: string | URL) => { + if (String(url).includes("/signals/reports/?")) { + return jsonResponse({ results: [{ id: "report-1" }] }); + } + postCount += 1; + if (postCount === 1) { + return new Response("{}", { status: 500 }); + } + return jsonResponse({ id: "artefact" }); + }); + + await reportCommitArtefacts({ + taskId: "task-1", + result: RESULT, + message: "fix: foo", + env: ENV, + envFilePath: NO_ENV_FILE, + }); + + // Both commits attempted despite the first failing. + expect(postCount).toBe(2); + }); +}); diff --git a/packages/agent/src/signed-commit-artefacts.ts b/packages/agent/src/signed-commit-artefacts.ts new file mode 100644 index 000000000..8d4e349e5 --- /dev/null +++ b/packages/agent/src/signed-commit-artefacts.ts @@ -0,0 +1,111 @@ +import { readFileSync } from "node:fs"; +import type { SignedCommitResult } from "@posthog/git/signed-commit"; +import { PostHogAPIClient } from "./posthog-api"; +import { SANDBOX_ENV_FILE } from "./utils/github-token"; + +/** + * Best-effort "commit hook": after a successful signed-commit push, record one `commit` + * artefact per pushed commit on every signal report the task is associated with, so the + * report's work log shows exactly what landed. Attribution is deterministic — the artefact + * endpoint reads the `X-PostHog-Task-Id` header, never the model. + * + * Credentials come from the sandbox environment (`POSTHOG_API_URL` / + * `POSTHOG_PERSONAL_API_KEY` / `POSTHOG_PROJECT_ID`), preferring the live agentsh env file + * for the key so a mid-session token refresh is picked up — the same pattern as + * `resolveGithubToken`. Works identically from the Claude in-process server and the Codex + * stdio child (both inherit the sandbox env). Never throws: a failed artefact post must not + * fail the commit that already landed. + */ + +interface SandboxPosthogApi { + apiUrl: string; + apiKey: string; + projectId: number; +} + +function readSandboxEnvFile(envFilePath: string): Record { + try { + const raw = readFileSync(envFilePath, "utf8"); + const env: Record = {}; + for (const entry of raw.split("\0")) { + const eq = entry.indexOf("="); + if (eq > 0) { + env[entry.slice(0, eq)] = entry.slice(eq + 1); + } + } + return env; + } catch { + // No env file (local/desktop or test) — fall back to the process env only. + return {}; + } +} + +export function resolveSandboxPosthogApi( + env: Record = process.env, + envFilePath: string = SANDBOX_ENV_FILE, +): SandboxPosthogApi | undefined { + const fileEnv = readSandboxEnvFile(envFilePath); + const apiUrl = fileEnv.POSTHOG_API_URL ?? env.POSTHOG_API_URL; + const apiKey = + fileEnv.POSTHOG_PERSONAL_API_KEY ?? env.POSTHOG_PERSONAL_API_KEY; + const projectId = Number( + fileEnv.POSTHOG_PROJECT_ID ?? env.POSTHOG_PROJECT_ID, + ); + if (!apiUrl || !apiKey || !Number.isFinite(projectId) || projectId <= 0) { + return undefined; + } + return { apiUrl, apiKey, projectId }; +} + +export async function reportCommitArtefacts(opts: { + taskId: string | undefined; + result: SignedCommitResult; + /** Commit headline — the same for every chunk of a split payload. */ + message: string; + env?: Record; + envFilePath?: string; +}): Promise { + const { taskId, result, message } = opts; + if (!taskId) { + return; // Local/desktop run — no task to attribute or associate through. + } + try { + const api = resolveSandboxPosthogApi(opts.env, opts.envFilePath); + if (!api) { + return; // No sandbox PostHog credentials — nothing to report to. + } + const client = new PostHogAPIClient({ + apiUrl: api.apiUrl, + projectId: api.projectId, + getApiKey: () => api.apiKey, + }); + const reportIds = await client.getSignalReportIdsForTask(taskId); + for (const reportId of reportIds) { + for (const commit of result.commits) { + try { + await client.createSignalReportArtefact(reportId, taskId, { + artefact_type: "commit", + content: { + repository: result.repository, + branch: result.branch, + commit_sha: commit.sha, + message, + }, + }); + } catch (err) { + warn( + `failed to record commit ${commit.sha} on report ${reportId}: ${err}`, + ); + } + } + } + } catch (err) { + warn(`failed to record commit artefacts: ${err}`); + } +} + +// stderr directly (not console) — this also runs inside the Codex stdio MCP child, +// where stdout is the protocol channel. +function warn(message: string): void { + process.stderr.write(`[signed-commit-artefacts] ${message}\n`); +} diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index 67cd4d19e..95d7a7bdc 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -386,6 +386,218 @@ describe("PostHogAPIClient", () => { }); }); + describe("getSignalReportArtefacts", () => { + function makeClient(fetch: ReturnType) { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { + baseUrl: "http://localhost:8000", + fetcher: { fetch }, + }; + return client; + } + + // One row per backend ArtefactType (products/signals/backend/models.py), + // content shapes mirroring artefact_schemas.py / real API payloads. + const ROWS = [ + { + id: "a1", + type: "video_segment", + content: { + session_id: "s1", + start_time: "2026-06-01T00:00:00Z", + end_time: "2026-06-01T00:01:00Z", + distinct_id: "d1", + content: "user rage-clicked the save button", + distance_to_centroid: 0.1, + }, + created_at: "2026-06-01T00:00:00Z", + }, + { + id: "a2", + type: "safety_judgment", + content: { choice: true, explanation: "No prompt injection found." }, + created_at: "2026-06-01T00:00:01Z", + task_id: "t1", + }, + { + id: "a3", + type: "actionability_judgment", + content: { + explanation: "Clear repro and code path.", + actionability: "immediately_actionable", + already_addressed: false, + }, + created_at: "2026-06-01T00:00:02Z", + }, + { + id: "a4", + type: "priority_judgment", + content: { explanation: "Cosmetic race.", priority: "P3" }, + created_at: "2026-06-01T00:00:03Z", + }, + { + id: "a5", + type: "signal_finding", + content: { + signal_id: "sig-1", + relevant_code_paths: ["a.ts"], + relevant_commit_hashes: { abc1234: "introduced the bug" }, + data_queried: "execute-sql", + verified: true, + }, + created_at: "2026-06-01T00:00:04Z", + }, + { + id: "a6", + type: "repo_selection", + content: { repository: "posthog/posthog", reason: "Caller provided." }, + created_at: "2026-06-01T00:00:05Z", + }, + { + id: "a7", + type: "suggested_reviewers", + content: [ + { + github_login: "octocat", + github_name: "Octo Cat", + relevant_commits: [], + user: null, + }, + ], + created_at: "2026-06-01T00:00:06Z", + }, + { + id: "a8", + type: "dismissal", + content: { + reason: "already_fixed", + note: "", + user_id: 1, + user_uuid: null, + }, + created_at: "2026-06-01T00:00:07Z", + }, + { + id: "a9", + type: "code_reference", + content: { + file_path: "src/a.ts", + start_line: 1, + end_line: 3, + contents: "let x = 1", + relevance_note: "origin", + }, + created_at: "2026-06-01T00:00:08Z", + }, + { + id: "a11", + type: "line_reference", + content: { + file_path: "src/a.ts", + line: 2, + note: "here", + contents: "x++", + }, + created_at: "2026-06-01T00:00:10Z", + }, + { + id: "a12", + type: "commit", + content: { + repository: "posthog/posthog", + branch: "main", + commit_sha: "abc1234", + message: "fix", + note: null, + }, + created_at: "2026-06-01T00:00:11Z", + }, + { + id: "a13", + type: "task_run", + content: { task_id: "t1", product: "tasks", type: "agent_run" }, + created_at: "2026-06-01T00:00:12Z", + task_id: "t1", + }, + { + id: "a14", + type: "note", + content: { note: "Guinea-pig probe note." }, + created_at: "2026-06-01T00:00:13Z", + task_id: "t1", + created_by: null, + }, + ]; + + it("normalizes every backend artefact type without dropping rows", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ count: ROWS.length, results: ROWS }), + }); + const client = makeClient(fetch); + + const { results, unavailableReason } = + await client.getSignalReportArtefacts("r1"); + + expect(unavailableReason).toBeUndefined(); + expect(results.map((a) => a.id)).toEqual(ROWS.map((r) => r.id)); + expect(results.map((a) => a.type)).toEqual(ROWS.map((r) => r.type)); + expect(results.every((a) => !a.degraded)).toBe(true); + }); + + it("keeps rows whose content does not match the type's shape as degraded previews", async () => { + const rows = [ + // commit missing branch/sha — must not vanish + { + id: "bad1", + type: "commit", + content: { repository: "posthog/posthog", message: "where am I" }, + created_at: "2026-06-01T00:00:00Z", + task_id: "t1", + }, + // unknown future type with arbitrary object content + { + id: "bad2", + type: "deploy_event", + content: { reason: "rolled back v2" }, + created_at: "2026-06-01T00:00:01Z", + }, + // empty content + { + id: "bad3", + type: "note", + content: {}, + created_at: "2026-06-01T00:00:02Z", + }, + ]; + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ count: rows.length, results: rows }), + }); + const client = makeClient(fetch); + + const { results } = await client.getSignalReportArtefacts("r1"); + + expect(results.map((a) => a.id)).toEqual(["bad1", "bad2", "bad3"]); + expect(results.every((a) => a.degraded)).toBe(true); + expect(results[0].type).toBe("commit"); + expect((results[1].content as { content: string }).content).toBe( + "rolled back v2", + ); + // attribution survives the fallback path + expect(results[0].task_id).toBe("t1"); + }); + }); + describe("updateSignalReportArtefact", () => { const ARTEFACT_PATH = "/api/projects/123/signals/reports/report-1/artefacts/art-1/"; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e5762e38a..10174c6ad 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -16,9 +16,15 @@ import type { ActionabilityJudgmentArtefact, AvailableSuggestedReviewer, AvailableSuggestedReviewersResponse, + CodeReferenceArtefact, + CommitArtefact, + CommitDiffResponse, DismissalArtefact, + LineReferenceArtefact, + NoteArtefact, PriorityJudgmentArtefact, RepoSelectionArtefact, + SafetyJudgmentArtefact, SandboxEnvironment, SandboxEnvironmentInput, Signal, @@ -30,8 +36,6 @@ import type { SignalReportSignalsResponse, SignalReportsQueryParams, SignalReportsResponse, - SignalReportTask, - SignalReportTaskRelationship, SignalTeamConfig, SignalUserAutonomyConfig, SlackChannelsQueryParams, @@ -40,6 +44,8 @@ import type { SuggestedReviewerWriteEntry, Task, TaskRun, + TaskRunArtefact, + UserBasic, } from "@posthog/shared/domain-types"; import { buildApiFetcher } from "./fetcher"; import { createApiClient, type Schemas } from "./generated"; @@ -427,14 +433,39 @@ function optionalString(value: unknown): string | null { return typeof value === "string" ? value : null; } +/** Unwrap the shared fetcher's `Failed request: [] ` into the endpoint's clean message. */ +function extractRequestErrorMessage(error: unknown, fallback: string): string { + const raw = error instanceof Error ? error.message : String(error); + const match = raw.match(/^Failed request: \[(\d+)\] (.*)$/s); + if (!match) { + return fallback; + } + try { + const body = JSON.parse(match[2]) as { error?: unknown; detail?: unknown }; + const message = body.error ?? body.detail; + if (typeof message === "string" && message.trim()) { + return message; + } + } catch { + // Non-JSON body — fall through to the status-based fallback. + } + return `${fallback} (HTTP ${match[1]})`; +} + type AnyArtefact = | SignalReportArtefact | PriorityJudgmentArtefact | ActionabilityJudgmentArtefact + | SafetyJudgmentArtefact | SignalFindingArtefact | RepoSelectionArtefact | SuggestedReviewersArtefact - | DismissalArtefact; + | DismissalArtefact + | CodeReferenceArtefact + | LineReferenceArtefact + | CommitArtefact + | TaskRunArtefact + | NoteArtefact; const DISMISSAL_REASONS = new Set( DISMISSAL_REASON_OPTIONS.map((o) => o.value), @@ -457,7 +488,7 @@ function normalizePriorityJudgmentArtefact( return { id, type: "priority_judgment", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + ...artefactBase(value), content: { explanation: optionalString(contentValue.explanation) ?? "", priority: priority as PriorityJudgmentArtefact["content"]["priority"], @@ -489,7 +520,7 @@ function normalizeActionabilityJudgmentArtefact( return { id, type: "actionability_judgment", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + ...artefactBase(value), content: { explanation: optionalString(contentValue.explanation) ?? "", actionability: @@ -502,6 +533,26 @@ function normalizeActionabilityJudgmentArtefact( }; } +function normalizeSafetyJudgmentArtefact( + value: Record, +): SafetyJudgmentArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + + const contentValue = isObjectRecord(value.content) ? value.content : null; + if (!contentValue || typeof contentValue.choice !== "boolean") return null; + + return { + id, + type: "safety_judgment", + ...artefactBase(value), + content: { + choice: contentValue.choice, + explanation: optionalString(contentValue.explanation), + }, + }; +} + function normalizeSignalFindingArtefact( value: Record, ): SignalFindingArtefact | null { @@ -517,7 +568,7 @@ function normalizeSignalFindingArtefact( return { id, type: "signal_finding", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + ...artefactBase(value), content: { signal_id: signalId, relevant_code_paths: Array.isArray(contentValue.relevant_code_paths) @@ -555,7 +606,7 @@ function normalizeRepoSelectionArtefact( return { id, type: "repo_selection", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + ...artefactBase(value), content: { repository: optionalString(contentValue.repository), reason: optionalString(contentValue.reason) ?? "", @@ -585,7 +636,7 @@ function normalizeDismissalArtefact( return { id, type: "dismissal", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + ...artefactBase(value), content: { reason, note: optionalString(contentValue.note) ?? "", @@ -596,6 +647,205 @@ function normalizeDismissalArtefact( }; } +// ── Log artefact normalizers ────────────────────────────────────────────── +// The backend stores log-artefact content as a JSON object (not the string-or- +// session_id shape the generic fallback expects), so each type needs an explicit +// normalizer — otherwise it falls through and gets dropped. + +/** User the artefact is attributed to, when the row carries a valid `created_by`. */ +function normalizeArtefactUser(value: unknown): UserBasic | null { + if (!isObjectRecord(value)) return null; + const id = value.id; + const uuid = optionalString(value.uuid); + const email = optionalString(value.email); + if (typeof id !== "number" || !uuid || !email) return null; + return { + id, + uuid, + email, + first_name: optionalString(value.first_name) ?? undefined, + last_name: optionalString(value.last_name) ?? undefined, + }; +} + +/** Row-level fields shared by every artefact: timestamps plus user/task attribution. */ +function artefactBase(value: Record): { + created_at: string; + updated_at: string | null; + created_by: UserBasic | null; + task_id: string | null; +} { + return { + created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + updated_at: optionalString(value.updated_at), + created_by: normalizeArtefactUser(value.created_by), + task_id: optionalString(value.task_id), + }; +} + +function normalizeCodeReferenceArtefact( + value: Record, +): CodeReferenceArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const file_path = optionalString(c.file_path); + if (!file_path) return null; + + return { + id, + type: "code_reference", + ...artefactBase(value), + content: { + file_path, + start_line: typeof c.start_line === "number" ? c.start_line : 0, + end_line: typeof c.end_line === "number" ? c.end_line : 0, + contents: optionalString(c.contents) ?? "", + relevance_note: optionalString(c.relevance_note) ?? "", + }, + }; +} + +function normalizeLineReferenceArtefact( + value: Record, +): LineReferenceArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const file_path = optionalString(c.file_path); + if (!file_path) return null; + + return { + id, + type: "line_reference", + ...artefactBase(value), + content: { + file_path, + line: typeof c.line === "number" ? c.line : 0, + note: optionalString(c.note) ?? "", + contents: optionalString(c.contents), + }, + }; +} + +function normalizeCommitArtefact( + value: Record, +): CommitArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const repository = optionalString(c.repository); + const branch = optionalString(c.branch); + const commit_sha = optionalString(c.commit_sha); + if (!repository || !branch || !commit_sha) return null; + + return { + id, + type: "commit", + ...artefactBase(value), + content: { + repository, + branch, + commit_sha, + message: optionalString(c.message) ?? "", + note: optionalString(c.note), + }, + }; +} + +function normalizeTaskRunArtefact( + value: Record, +): TaskRunArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const task_id = optionalString(c.task_id); + if (!task_id) return null; + const product = optionalString(c.product); + const type = optionalString(c.type); + if (!product || !type) return null; + + return { + id, + type: "task_run", + ...artefactBase(value), + content: { + task_id, + run_id: optionalString(c.run_id), + product, + type, + }, + }; +} + +function normalizeNoteArtefact( + value: Record, +): NoteArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const note = optionalString(c.note); + if (!note) return null; + + return { + id, + type: "note", + ...artefactBase(value), + content: { + note, + author: optionalString(c.author), + }, + }; +} + +/** Best human-readable one-liner from arbitrary artefact content. */ +function contentPreview(content: unknown): string { + if (typeof content === "string") return content; + if (isObjectRecord(content)) { + for (const key of ["note", "explanation", "reason", "message", "content"]) { + const v = content[key]; + if (typeof v === "string" && v.trim()) return v; + } + } + try { + const text = JSON.stringify(content); + return text && text !== "{}" && text !== "null" ? text.slice(0, 300) : ""; + } catch { + return ""; + } +} + +/** + * Last-resort normalizer: keeps the row (type, timestamps, attribution, a text + * preview) when its content doesn't match the type's expected shape, so an + * artefact never silently vanishes from the activity log. + */ +function normalizeFallbackArtefact( + value: Record, +): SignalReportArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + return { + id, + type: optionalString(value.type) ?? "unknown", + degraded: true, + ...artefactBase(value), + content: { + session_id: "", + start_time: "", + end_time: "", + distinct_id: "", + content: contentPreview(value.content), + distance_to_centroid: null, + }, + }; +} + function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { if (!isObjectRecord(value)) { return null; @@ -603,19 +853,55 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { const dispatchType = optionalString(value.type); if (dispatchType === "signal_finding") { - return normalizeSignalFindingArtefact(value); + return ( + normalizeSignalFindingArtefact(value) ?? normalizeFallbackArtefact(value) + ); } if (dispatchType === "actionability_judgment") { - return normalizeActionabilityJudgmentArtefact(value); + return ( + normalizeActionabilityJudgmentArtefact(value) ?? + normalizeFallbackArtefact(value) + ); + } + if (dispatchType === "safety_judgment") { + return ( + normalizeSafetyJudgmentArtefact(value) ?? normalizeFallbackArtefact(value) + ); } if (dispatchType === "priority_judgment") { - return normalizePriorityJudgmentArtefact(value); + return ( + normalizePriorityJudgmentArtefact(value) ?? + normalizeFallbackArtefact(value) + ); } if (dispatchType === "repo_selection") { - return normalizeRepoSelectionArtefact(value); + return ( + normalizeRepoSelectionArtefact(value) ?? normalizeFallbackArtefact(value) + ); } if (dispatchType === "dismissal") { - return normalizeDismissalArtefact(value); + return ( + normalizeDismissalArtefact(value) ?? normalizeFallbackArtefact(value) + ); + } + if (dispatchType === "code_reference") { + return ( + normalizeCodeReferenceArtefact(value) ?? normalizeFallbackArtefact(value) + ); + } + if (dispatchType === "line_reference") { + return ( + normalizeLineReferenceArtefact(value) ?? normalizeFallbackArtefact(value) + ); + } + if (dispatchType === "commit") { + return normalizeCommitArtefact(value) ?? normalizeFallbackArtefact(value); + } + if (dispatchType === "task_run") { + return normalizeTaskRunArtefact(value) ?? normalizeFallbackArtefact(value); + } + if (dispatchType === "note") { + return normalizeNoteArtefact(value) ?? normalizeFallbackArtefact(value); } const id = optionalString(value.id); @@ -624,15 +910,13 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { } const type = dispatchType ?? "unknown"; - const created_at = - optionalString(value.created_at) ?? new Date(0).toISOString(); // suggested_reviewers: content is an array of reviewer objects if (type === "suggested_reviewers" && Array.isArray(value.content)) { return { id, type: "suggested_reviewers" as const, - created_at, + ...artefactBase(value), content: value.content as SuggestedReviewersArtefact["content"], }; } @@ -640,7 +924,7 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { // video_segment and other artefacts with object content const contentValue = isObjectRecord(value.content) ? value.content : null; if (!contentValue) { - return null; + return normalizeFallbackArtefact(value); } const content = optionalString(contentValue.content); @@ -648,13 +932,13 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { // The backend may return empty content objects when binary decode fails. if (!content && !sessionId) { - return null; + return normalizeFallbackArtefact(value); } return { id, type, - created_at, + ...artefactBase(value), content: { session_id: sessionId ?? "", start_time: optionalString(contentValue.start_time) ?? "", @@ -1553,8 +1837,6 @@ export class PostHogAPIClient { > & { github_integration?: number | null; github_user_integration?: string | null; - /** POST-only: `SignalReportTask.relationship` to create when linking to `signal_report`. */ - signal_report_task_relationship?: SignalReportTaskRelationship; }, ) { const teamId = await this.getTeamId(); @@ -2686,6 +2968,32 @@ export class PostHogAPIClient { } } + async getCommitDiff( + reportId: string, + artefactId: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/signals/reports/${reportId}/artefacts/${artefactId}/diff/`; + const url = new URL(`${this.api.baseUrl}${path}`); + + // The shared fetcher throws `Failed request: [] ` for any non-2xx, so + // unwrap that into the endpoint's clean `error` message rather than surfacing the raw string. + let response: Response; + try { + response = await this.api.fetcher.fetch({ method: "get", url, path }); + } catch (error) { + throw new Error( + extractRequestErrorMessage(error, "Couldn\u2019t load the diff."), + ); + } + + const data = (await response.json()) as Partial; + return { + diff: typeof data.diff === "string" ? data.diff : "", + truncated: data.truncated === true, + }; + } + async updateSignalReportState( reportId: string, input: @@ -2727,6 +3035,12 @@ export class PostHogAPIClient { return (await response.json()) as SignalReport; } + /** + * Edit a report's suggested reviewers. The server appends a new `suggested_reviewers` status + * artefact (latest-wins), canonicalizes each entry to a lowercase `github_login`, and carries + * `relevant_commits` / `github_name` forward from the current reviewers for surviving logins. + * Returns the newly-appended artefact (a fresh id), not the one addressed by `artefactId`. + */ async updateSignalReportArtefact( reportId: string, artefactId: string, @@ -2813,35 +3127,6 @@ export class PostHogAPIClient { }; } - async getSignalReportTasks( - reportId: string, - options?: { relationship?: SignalReportTask["relationship"] }, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/tasks/`, - ); - if (options?.relationship) { - url.searchParams.set("relationship", options.relationship); - } - const path = `/api/projects/${teamId}/signals/reports/${reportId}/tasks/`; - - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch signal report tasks: ${response.statusText}`, - ); - } - - const data = (await response.json()) as { results?: SignalReportTask[] }; - return data.results ?? []; - } - async getSignalTeamConfig(): Promise { const teamId = await this.getTeamId(); const url = new URL( diff --git a/packages/core/src/inbox/reportArtefacts.ts b/packages/core/src/inbox/reportArtefacts.ts index 9373cca87..d03fd595c 100644 --- a/packages/core/src/inbox/reportArtefacts.ts +++ b/packages/core/src/inbox/reportArtefacts.ts @@ -10,12 +10,31 @@ import type { type ReportArtefact = SignalReportArtefactsResponse["results"][number]; +// Artefacts are an append-only history: status types (judgments, reviewers) are +// latest-wins, and `signal_finding` is keyed by signal_id with the latest version +// per signal winning. ISO-8601 `created_at` strings compare lexicographically in +// chronological order, so selection is order-independent rather than relying on +// the API's `-created_at` response ordering. +function latestOfType( + artefacts: ReportArtefact[], + type: T["type"], +): T | null { + let latest: T | null = null; + for (const a of artefacts) { + if (a.type === type && (!latest || a.created_at > latest.created_at)) { + latest = a as T; + } + } + return latest; +} + export function selectSuggestedReviewers( artefacts: ReportArtefact[], meUuid?: string, ): SuggestedReviewer[] { - const artefact = artefacts.find( - (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", + const artefact = latestOfType( + artefacts, + "suggested_reviewers", ); const reviewers = artefact?.content ?? []; if (!meUuid) return reviewers; @@ -27,34 +46,38 @@ export function selectSuggestedReviewers( export function buildSignalFindingMap( artefacts: ReportArtefact[], ): Map { - const map = new Map(); + const latestBySignal = new Map(); for (const a of artefacts) { - if (a.type === "signal_finding") { - const finding = a as SignalFindingArtefact; - map.set(finding.content.signal_id, finding.content); + if (a.type !== "signal_finding") continue; + const finding = a as SignalFindingArtefact; + const existing = latestBySignal.get(finding.content.signal_id); + if (!existing || finding.created_at > existing.created_at) { + latestBySignal.set(finding.content.signal_id, finding); } } + const map = new Map(); + for (const [signalId, finding] of latestBySignal) { + map.set(signalId, finding.content); + } return map; } export function selectActionabilityJudgment( artefacts: ReportArtefact[], ): ActionabilityJudgmentContent | null { - for (const a of artefacts) { - if (a.type === "actionability_judgment") { - return (a as ActionabilityJudgmentArtefact).content; - } - } - return null; + return ( + latestOfType( + artefacts, + "actionability_judgment", + )?.content ?? null + ); } export function selectPriorityExplanation( artefacts: ReportArtefact[], ): string | null { - for (const a of artefacts) { - if (a.type === "priority_judgment") { - return (a as PriorityJudgmentArtefact).content.explanation || null; - } - } - return null; + return ( + latestOfType(artefacts, "priority_judgment") + ?.content.explanation || null + ); } diff --git a/packages/core/src/inbox/reportRepository.ts b/packages/core/src/inbox/reportRepository.ts index 5fb7ebe27..a5e9f1ee9 100644 --- a/packages/core/src/inbox/reportRepository.ts +++ b/packages/core/src/inbox/reportRepository.ts @@ -1,20 +1,20 @@ -import type { SignalReportTask, Task } from "@posthog/shared/domain-types"; - -export const REPOSITORY_SOURCE_RELATIONSHIPS: SignalReportTask["relationship"][] = - ["repo_selection", "research", "implementation"]; +import type { Task } from "@posthog/shared/domain-types"; +/** + * Resolve the repository a report's work happened in. Association is derived + * from `task_run` artefacts — the repository is simply the first one any + * associated task carries, walking oldest-first (repo selection / research + * precede implementation). + */ export async function resolveReportRepository( - reportTasks: SignalReportTask[], + associatedTasks: Array<{ taskId: string; startedAt: string }>, getTask: (taskId: string) => Promise, ): Promise { - for (const relationship of REPOSITORY_SOURCE_RELATIONSHIPS) { - const reportTask = reportTasks.find( - (task) => task.relationship === relationship, - ); - if (!reportTask) { - continue; - } - const task = await getTask(reportTask.task_id); + const ordered = [...associatedTasks].sort((a, b) => + a.startedAt.localeCompare(b.startedAt), + ); + for (const entry of ordered) { + const task = await getTask(entry.taskId); if (task?.repository) { return task.repository.toLowerCase(); } diff --git a/packages/core/src/inbox/reportTasks.ts b/packages/core/src/inbox/reportTasks.ts index 8f2d45fc9..999f319d8 100644 --- a/packages/core/src/inbox/reportTasks.ts +++ b/packages/core/src/inbox/reportTasks.ts @@ -1,35 +1,4 @@ -import type { SignalReportTask, Task } from "@posthog/shared/domain-types"; - -export type ReportTaskRelationship = SignalReportTask["relationship"]; - -export const DISPLAYED_RELATIONSHIPS: ReportTaskRelationship[] = [ - "implementation", - "research", -]; - -export interface ReportTaskData { - task: Task; - relationship: ReportTaskRelationship; - startedAt: string; -} - -/** Keep only report-task relationships that the detail pane renders. */ -export function selectDisplayedReportTasks( - reportTasks: SignalReportTask[], -): SignalReportTask[] { - return reportTasks.filter((rt) => - DISPLAYED_RELATIONSHIPS.includes(rt.relationship), - ); -} - -/** Sort report tasks by their relationship's display rank. */ -export function sortByRelationship(tasks: ReportTaskData[]): ReportTaskData[] { - return [...tasks].sort( - (a, b) => - DISPLAYED_RELATIONSHIPS.indexOf(a.relationship) - - DISPLAYED_RELATIONSHIPS.indexOf(b.relationship), - ); -} +import type { Task } from "@posthog/shared/domain-types"; /** Extract the PR url from a task's latest run output, if present. */ export function getTaskPrUrl(task: Task): string | null { diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 42a532292..96e9e9f45 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -12,10 +12,7 @@ import { type Workspace, } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { - SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP, - type Task, -} from "@posthog/shared/domain-types"; +import type { Task } from "@posthog/shared/domain-types"; import type { TaskCreationApiClient } from "./taskCreationApiClient"; import type { ITaskCreationHost } from "./taskCreationHost"; @@ -416,10 +413,9 @@ export class TaskCreationSaga extends Saga< origin_product: input.signalReportId ? "signal_report" : "user_created", + // The server associates the task with the report and records the implementation + // task_run artefact — no relationship label is sent (associations are unlabelled). signal_report: input.signalReportId ?? undefined, - signal_report_task_relationship: input.signalReportId - ? SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP - : undefined, }); return result as unknown as Task; }, diff --git a/packages/git/src/signed-commit.ts b/packages/git/src/signed-commit.ts index 820d20ed1..bf6fba7e2 100644 --- a/packages/git/src/signed-commit.ts +++ b/packages/git/src/signed-commit.ts @@ -55,6 +55,8 @@ export interface SignedCommitInput { export interface SignedCommitResult { branch: string; + /** Repository the commits were pushed to, as `owner/repo` (from the origin remote). */ + repository: string; /** One entry per chunk; >1 only when the payload was split. */ commits: { sha: string; url: string }[]; } @@ -822,7 +824,7 @@ export async function createSignedCommit( (await runGit(["diff", "--cached", "--quiet", "HEAD"], ctx.cwd)) .exitCode !== 0; if (hasStagedChanges) { - return { branch, commits: [] }; + return { branch, repository: repo, commits: [] }; } throw new Error( "No staged changes to commit. Stage files with `git add` first (or pass `paths`).", @@ -846,7 +848,7 @@ export async function createSignedCommit( ); await syncLocalCheckout(ctx, branch, newTip); - return { branch, commits }; + return { branch, repository: repo, commits }; } /** Splits a raw commit message into a headline and the remaining body */ @@ -990,7 +992,7 @@ export async function createSignedRewrite( } await forceUpdateRef(ctx, repo, branch, expectedHeadOid); await syncLocalCheckout(ctx, branch, expectedHeadOid); - return { branch, commits }; + return { branch, repository: repo, commits }; } finally { // The history is already published via the ref move; the scratch ref is just // bookkeeping, so a delete failure is non-fatal. diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index a616fa634..683671223 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -26,7 +26,7 @@ export const effortLevelSchema = z.enum([ ]); export type EffortLevel = z.infer; -interface UserBasic { +export interface UserBasic { id: number; uuid: string; distinct_id?: string | null; @@ -305,19 +305,36 @@ export interface SignalReportArtefactContent { distance_to_centroid: number | null; } -export interface SignalReportArtefact { +/** + * Fields shared by every artefact row. `created_by` / `task_id` carry attribution: + * at most one is set — `created_by` for user writes, `task_id` for agent writes, + * neither for system (pipeline) writes. + */ +export interface SignalReportArtefactBase { id: string; + created_at: string; + updated_at?: string | null; + /** User the artefact is attributed to, when a user produced it. */ + created_by?: UserBasic | null; + /** Task the artefact is attributed to, when an agent produced it. */ + task_id?: string | null; + /** + * True when the row's content did not match its type's expected shape and was + * normalized to a plain text preview instead — the entry still renders rather + * than silently vanishing from the activity log. + */ + degraded?: boolean; +} + +export interface SignalReportArtefact extends SignalReportArtefactBase { type: string; content: SignalReportArtefactContent; - created_at: string; } /** Artefact with `type: "priority_judgment"` — priority assessment from the agentic report. */ -export interface PriorityJudgmentArtefact { - id: string; +export interface PriorityJudgmentArtefact extends SignalReportArtefactBase { type: "priority_judgment"; content: PriorityJudgmentContent; - created_at: string; } export interface PriorityJudgmentContent { @@ -326,11 +343,10 @@ export interface PriorityJudgmentContent { } /** Artefact with `type: "actionability_judgment"` — actionability assessment from the agentic report. */ -export interface ActionabilityJudgmentArtefact { - id: string; +export interface ActionabilityJudgmentArtefact + extends SignalReportArtefactBase { type: "actionability_judgment"; content: ActionabilityJudgmentContent; - created_at: string; } export interface ActionabilityJudgmentContent { @@ -339,12 +355,23 @@ export interface ActionabilityJudgmentContent { already_addressed: boolean; } +/** Artefact with `type: "safety_judgment"` — the prompt-injection safety verdict for the report. */ +export interface SafetyJudgmentArtefact extends SignalReportArtefactBase { + type: "safety_judgment"; + content: SafetyJudgmentContent; +} + +export interface SafetyJudgmentContent { + /** True when the report's signals are judged safe to act on. */ + choice: boolean; + /** Why the report was judged unsafe; null when safe. */ + explanation: string | null; +} + /** Artefact with `type: "signal_finding"` — per-signal research finding from the agentic report. */ -export interface SignalFindingArtefact { - id: string; +export interface SignalFindingArtefact extends SignalReportArtefactBase { type: "signal_finding"; content: SignalFindingContent; - created_at: string; } export interface SignalFindingContent { @@ -356,11 +383,9 @@ export interface SignalFindingContent { } /** Artefact with `type: "repo_selection"` - selected repository for the report run. */ -export interface RepoSelectionArtefact { - id: string; +export interface RepoSelectionArtefact extends SignalReportArtefactBase { type: "repo_selection"; content: RepoSelectionContent; - created_at: string; } export interface RepoSelectionContent { @@ -369,19 +394,15 @@ export interface RepoSelectionContent { } /** Artefact with `type: "suggested_reviewers"` — content is an enriched reviewer list. */ -export interface SuggestedReviewersArtefact { - id: string; +export interface SuggestedReviewersArtefact extends SignalReportArtefactBase { type: "suggested_reviewers"; content: SuggestedReviewer[]; - created_at: string; } /** Artefact with `type: "dismissal"` — captures the user's rationale when suppressing a report. */ -export interface DismissalArtefact { - id: string; +export interface DismissalArtefact extends SignalReportArtefactBase { type: "dismissal"; content: DismissalContent; - created_at: string; } export interface DismissalContent { @@ -394,6 +415,93 @@ export interface DismissalContent { user_uuid: string | null; } +// ── Log artefacts ──────────────────────────────────────────────────────────── +// Append-but-deletable "work log" entries that accumulate on a report. Distinct +// from the status artefacts above (judgments, reviewers) which are latest-wins. +// Content shapes mirror products/signals/backend/artefact_schemas.py. + +/** Artefact with `type: "code_reference"` — a contiguous span of source lines. */ +export interface CodeReferenceArtefact extends SignalReportArtefactBase { + type: "code_reference"; + content: CodeReferenceContent; +} + +export interface CodeReferenceContent { + file_path: string; + start_line: number; + end_line: number; + contents: string; + relevance_note: string; +} + +/** Artefact with `type: "line_reference"` — a single source line callout (a point). */ +export interface LineReferenceArtefact extends SignalReportArtefactBase { + type: "line_reference"; + content: LineReferenceContent; +} + +export interface LineReferenceContent { + file_path: string; + line: number; + note: string; + /** The exact source text of the referenced line, if available. */ + contents?: string | null; +} + +/** Artefact with `type: "commit"` — one commit pushed in relation to the report. */ +export interface CommitArtefact extends SignalReportArtefactBase { + type: "commit"; + content: CommitContent; +} + +export interface CommitContent { + repository: string; + branch: string; + commit_sha: string; + message: string; + note?: string | null; +} + +/** Artefact with `type: "task_run"` — a reference to a `tasks.Task` run for the report. */ +export interface TaskRunArtefact extends SignalReportArtefactBase { + type: "task_run"; + content: TaskRunArtefactContent; +} + +export interface TaskRunArtefactContent { + task_id: string; + run_id?: string | null; + /** + * Product that ran the task — `signals` for the built-in pipeline, or a custom agent's + * product identifier (mirrors backend TaskRunArtefact). + */ + product: string; + /** + * Task type within the product — e.g. `research` / `implementation` / `repo_selection` for the + * signals pipeline, or a custom agent's type identifier. + */ + type: string; +} + +/** Artefact with `type: "note"` — a free-form note authored by an agent or by code. */ +export interface NoteArtefact extends SignalReportArtefactBase { + type: "note"; + content: NoteContent; +} + +export interface NoteContent { + note: string; + author?: string | null; +} + +/** Response from the `commit` artefact diff endpoint — the commit rendered against its parent. */ +export interface CommitDiffResponse { + /** Unified diff (patch) text introduced by the commit. */ + diff: string; + /** True when the diff was too large to return in full and has been truncated. */ + truncated: boolean; +} + export interface SuggestedReviewerCommit { sha: string; url: string; @@ -468,16 +576,24 @@ export interface SignalReportSignalsResponse { signals: Signal[]; } +/** Any artefact returned by the report `artefacts/` endpoint, discriminated on `type`. */ +export type AnySignalReportArtefact = + | SignalReportArtefact + | PriorityJudgmentArtefact + | ActionabilityJudgmentArtefact + | SafetyJudgmentArtefact + | SignalFindingArtefact + | RepoSelectionArtefact + | SuggestedReviewersArtefact + | DismissalArtefact + | CodeReferenceArtefact + | LineReferenceArtefact + | CommitArtefact + | TaskRunArtefact + | NoteArtefact; + export interface SignalReportArtefactsResponse { - results: ( - | SignalReportArtefact - | PriorityJudgmentArtefact - | ActionabilityJudgmentArtefact - | SignalFindingArtefact - | RepoSelectionArtefact - | SuggestedReviewersArtefact - | DismissalArtefact - )[]; + results: AnySignalReportArtefact[]; count: number; unavailableReason?: | "forbidden" @@ -507,27 +623,6 @@ export interface SignalReportsQueryParams { priority?: string; } -/** Values match `SignalReportTask.Relationship` on the PostHog API. */ -export const SIGNAL_REPORT_TASK_RELATIONSHIPS = [ - "repo_selection", - "research", - "implementation", -] as const; - -export type SignalReportTaskRelationship = - (typeof SIGNAL_REPORT_TASK_RELATIONSHIPS)[number]; - -/** Inbox / cloud PR tasks must use this when creating the `SignalReportTask` link. */ -export const SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP: SignalReportTaskRelationship = - "implementation"; - -export interface SignalReportTask { - id: string; - relationship: SignalReportTaskRelationship; - task_id: string; - created_at: string; -} - export interface SignalTeamConfig { id: string; default_autostart_priority: SignalReportPriority; diff --git a/packages/ui/src/features/inbox/components/AgentRunDetail.tsx b/packages/ui/src/features/inbox/components/AgentRunDetail.tsx index d98e7078f..4a9537a50 100644 --- a/packages/ui/src/features/inbox/components/AgentRunDetail.tsx +++ b/packages/ui/src/features/inbox/components/AgentRunDetail.tsx @@ -16,8 +16,6 @@ import { Button } from "@posthog/quill"; import { isTerminalStatus, type SignalReport, - type SignalReportTaskRelationship, - type Task, type TaskRunStatus, } from "@posthog/shared/types"; import { @@ -25,6 +23,7 @@ import { resolveRunVariant, } from "@posthog/ui/features/inbox/components/AgentRunCard"; import { DetailSection } from "@posthog/ui/features/inbox/components/DetailSection"; +import { ReportActivitySection } from "@posthog/ui/features/inbox/components/detail/ReportActivitySection"; import { InboxDetailPageHeader } from "@posthog/ui/features/inbox/components/InboxDetailPageHeader"; import { InboxMetaSeparator, @@ -47,7 +46,10 @@ import { hasKnownSourceProduct, } from "@posthog/ui/features/inbox/components/utils/source-product-icons"; import { useInboxReportSignals } from "@posthog/ui/features/inbox/hooks/useInboxReports"; -import { useReportTasks } from "@posthog/ui/features/inbox/hooks/useReportTasks"; +import { + type ReportTaskData, + useReportTasks, +} from "@posthog/ui/features/inbox/hooks/useReportTasks"; import { TaskLogsPanel } from "@posthog/ui/features/task-detail/components/TaskLogsPanel"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { openTask } from "@posthog/ui/router/useOpenTask"; @@ -72,21 +74,8 @@ export function TaskRunStatusDot({ status }: { status: TaskRunStatus }) { ); } -export const RELATIONSHIP_LABEL: Record = - { - research: "Research", - implementation: "Implementation", - repo_selection: "Repo selection", - }; - -interface RelevantTask { - task: Task; - relationship: SignalReportTaskRelationship; - startedAt: string; -} - /** Prefer in-motion tasks; tie-break by most-recently-created. */ -function pickPrimaryTask(tasks: RelevantTask[]): RelevantTask | null { +function pickPrimaryTask(tasks: ReportTaskData[]): ReportTaskData | null { if (tasks.length === 0) return null; return [...tasks].sort((a, b) => { const aInMotion = !isTerminalStatus(a.task.latest_run?.status ?? ""); @@ -261,9 +250,7 @@ function AgentRunDetailContent({ report }: { report: SignalReport }) { // regardless of how the first attempt ended. const isReResearch = useMemo(() => { if (!reportTasks) return false; - const researchTasks = reportTasks.filter( - (rt) => rt.relationship === "research", - ); + const researchTasks = reportTasks.filter((rt) => rt.purpose === "research"); if (researchTasks.length < 2) return false; const hasInFlight = researchTasks.some( (rt) => @@ -444,6 +431,7 @@ function AgentRunDetailContent({ report }: { report: SignalReport }) { )} )} + @@ -456,8 +444,8 @@ function TaskLogRightSlot({ selectedEntry, onSelect, }: { - entries: RelevantTask[]; - selectedEntry: RelevantTask | null | undefined; + entries: ReportTaskData[]; + selectedEntry: ReportTaskData | null | undefined; onSelect: (id: string) => void; }) { if (!selectedEntry) return null; @@ -479,7 +467,7 @@ function TaskLogRightSlot({ - {RELATIONSHIP_LABEL[selectedEntry.relationship]} + {selectedEntry.purposeLabel} @@ -494,7 +482,7 @@ function TaskLogRightSlot({ - {RELATIONSHIP_LABEL[entry.relationship]} + {entry.purposeLabel} {entry.task.id.slice(0, 8)} diff --git a/packages/ui/src/features/inbox/components/PullRequestDetail.tsx b/packages/ui/src/features/inbox/components/PullRequestDetail.tsx index 4f2c780a8..460aa0bb1 100644 --- a/packages/ui/src/features/inbox/components/PullRequestDetail.tsx +++ b/packages/ui/src/features/inbox/components/PullRequestDetail.tsx @@ -6,6 +6,7 @@ import { import { parsePrUrl } from "@posthog/core/inbox/reportPresentation"; import { Button } from "@posthog/quill"; import type { SignalReport } from "@posthog/shared/types"; +import { ReportActivitySection } from "@posthog/ui/features/inbox/components/detail/ReportActivitySection"; import { InboxDetailFrame } from "@posthog/ui/features/inbox/components/InboxDetailFrame"; import { InboxMetaSeparator } from "@posthog/ui/features/inbox/components/InboxMetaRow"; import { InboxReportDetailGate } from "@posthog/ui/features/inbox/components/InboxReportDetailGate"; @@ -98,6 +99,7 @@ function PullRequestDetailContent({ report }: { report: SignalReport }) { > + ); } diff --git a/packages/ui/src/features/inbox/components/ReportDetail.tsx b/packages/ui/src/features/inbox/components/ReportDetail.tsx index 1d10a13b4..ae2f76bb7 100644 --- a/packages/ui/src/features/inbox/components/ReportDetail.tsx +++ b/packages/ui/src/features/inbox/components/ReportDetail.tsx @@ -5,6 +5,7 @@ import { } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; import type { SignalReport } from "@posthog/shared/types"; +import { ReportActivitySection } from "@posthog/ui/features/inbox/components/detail/ReportActivitySection"; import { InboxDetailFrame } from "@posthog/ui/features/inbox/components/InboxDetailFrame"; import { InboxReportDetailGate } from "@posthog/ui/features/inbox/components/InboxReportDetailGate"; import { ReportDetailActions } from "@posthog/ui/features/inbox/components/ReportDetailActions"; @@ -68,6 +69,7 @@ function ReportDetailContent({ report }: { report: SignalReport }) { > + ); } diff --git a/packages/ui/src/features/inbox/components/ReportTasksSection.tsx b/packages/ui/src/features/inbox/components/ReportTasksSection.tsx index c4da968d5..905d6bb3a 100644 --- a/packages/ui/src/features/inbox/components/ReportTasksSection.tsx +++ b/packages/ui/src/features/inbox/components/ReportTasksSection.tsx @@ -1,13 +1,6 @@ import { ArrowSquareOutIcon, TerminalIcon } from "@phosphor-icons/react"; -import type { - SignalReport, - SignalReportTaskRelationship, - Task, -} from "@posthog/shared/types"; -import { - RELATIONSHIP_LABEL, - TaskRunStatusDot, -} from "@posthog/ui/features/inbox/components/AgentRunDetail"; +import type { SignalReport, Task } from "@posthog/shared/types"; +import { TaskRunStatusDot } from "@posthog/ui/features/inbox/components/AgentRunDetail"; import { RightColumnSection } from "@posthog/ui/features/inbox/components/RightColumnSection"; import { useReportTasks } from "@posthog/ui/features/inbox/hooks/useReportTasks"; import { Flex, Text } from "@radix-ui/themes"; @@ -30,11 +23,11 @@ export function ReportTasksSection({ report }: ReportTasksSectionProps) { return ( - {reportTasks.map(({ task, relationship }) => ( + {reportTasks.map(({ task, purposeLabel }) => ( navigate({ to: "/code/inbox/runs/$reportId", @@ -50,11 +43,11 @@ export function ReportTasksSection({ report }: ReportTasksSectionProps) { function TaskRow({ task, - relationship, + purposeLabel, onOpen, }: { task: Task; - relationship: SignalReportTaskRelationship; + purposeLabel: string; onOpen: () => void; }) { const status = task.latest_run?.status ?? "not_started"; @@ -65,9 +58,7 @@ function TaskRow({ className="group flex cursor-default select-none items-center gap-2 rounded-(--radius-1) px-1.5 py-1 text-left text-[11px] transition-colors hover:bg-(--gray-3)" > - - {RELATIONSHIP_LABEL[relationship]} - + {purposeLabel} {task.title || "Untitled"} diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactCommit.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactCommit.tsx new file mode 100644 index 000000000..ff924493b --- /dev/null +++ b/packages/ui/src/features/inbox/components/detail/ArtefactCommit.tsx @@ -0,0 +1,95 @@ +import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react"; +import { inboxReportKeys } from "@posthog/core/inbox/inboxQuery"; +import type { CommitContent } from "@posthog/shared/types"; +import { DiffBlock } from "@posthog/ui/features/inbox/components/detail/DiffBlock"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { Box, Flex, Spinner, Text } from "@radix-ui/themes"; +import { useState } from "react"; + +/** + * Renders a `commit` artefact: commit metadata plus a collapsible diff of the commit against + * its parent, fetched lazily (on first expand) from the team's GitHub integration. + */ +export function ArtefactCommit({ + reportId, + artefactId, + content, +}: { + reportId: string; + artefactId: string; + content: CommitContent; +}) { + const [expanded, setExpanded] = useState(false); + + const diffQuery = useAuthenticatedQuery( + [...inboxReportKeys.artefacts(reportId), artefactId, "diff"], + (client) => client.getCommitDiff(reportId, artefactId), + // Only fetch once expanded; a commit's diff is immutable. + { enabled: expanded, staleTime: 5 * 60_000, retry: false }, + ); + + return ( + + + {content.message} + + + {content.commit_sha.slice(0, 12)} · {content.repository}@ + {content.branch} + + {content.note?.trim() ? ( + + {content.note} + + ) : null} + + + + {expanded ? ( + + {diffQuery.isLoading ? ( + + + Fetching diff… + + ) : diffQuery.isError ? ( + + {diffQuery.error instanceof Error + ? diffQuery.error.message + : "Couldn’t load the diff."} + + ) : diffQuery.data?.diff.trim() ? ( + <> + + {diffQuery.data.truncated ? ( + + Diff truncated — too large to display in full. + + ) : null} + + ) : ( + + No changes recorded for this commit. + + )} + + ) : null} + + ); +} diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx new file mode 100644 index 000000000..9ff933726 --- /dev/null +++ b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx @@ -0,0 +1,476 @@ +import { + ArrowSquareOutIcon, + CaretDownIcon, + CaretRightIcon, +} from "@phosphor-icons/react"; +import type { + ActionabilityJudgmentContent, + AnySignalReportArtefact, + CodeReferenceContent, + CommitContent, + DismissalContent, + LineReferenceContent, + NoteContent, + PriorityJudgmentContent, + SafetyJudgmentContent, + SignalFindingContent, + SignalReportArtefactContent, + SuggestedReviewer, + TaskRunArtefactContent, +} from "@posthog/shared/types"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { ArtefactCommit } from "@posthog/ui/features/inbox/components/detail/ArtefactCommit"; +import { ArtefactTaskRun } from "@posthog/ui/features/inbox/components/detail/ArtefactTaskRun"; +import { SignalReportActionabilityBadge } from "@posthog/ui/features/inbox/components/utils/SignalReportActionabilityBadge"; +import { SignalReportPriorityBadge } from "@posthog/ui/features/inbox/components/utils/SignalReportPriorityBadge"; +import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; +import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import { useState } from "react"; + +// A chronological log of every artefact on a report. Each known type renders a +// tailored body; unrecognized types fall back to a plain text preview (never raw +// JSON). Timeline chrome (rails, grouping) is a follow-up — this is the polished +// content layer. + +// Each entry is framed as a point-in-time action ("what happened"), since the log is an +// append-only history of changes — the current status lives at the top of the report. +const TYPE_LABELS: Record = { + code_reference: "Code referenced", + line_reference: "Line highlighted", + commit: "Commit pushed", + task_run: "Task run", + note: "Note added", + priority_judgment: "Priority assessed", + actionability_judgment: "Actionability assessed", + safety_judgment: "Safety assessed", + signal_finding: "Signal investigated", + suggested_reviewers: "Reviewers suggested", + repo_selection: "Repo selected", + dismissal: "Report dismissed", + video_segment: "Video segment", +}; + +function typeLabel(type: string): string { + return TYPE_LABELS[type] ?? type; +} + +function prettify(value: string): string { + const cleaned = value.replace(/[-_]/g, " ").trim(); + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); +} + +/** Map a file path to a syntax-highlight language key (extension-based). */ +function languageFromPath(filePath: string): string { + return filePath.split(".").pop()?.toLowerCase() ?? ""; +} + +/** + * Who produced the artefact: a user's name, "agent" for task-attributed writes, + * or null for system (pipeline) writes and pre-attribution rows. + */ +function attributionLabel(artefact: AnySignalReportArtefact): string | null { + if (artefact.created_by) { + return artefact.created_by.first_name?.trim() || artefact.created_by.email; + } + if (artefact.task_id) { + return "agent"; + } + return null; +} + +// The generic `SignalReportArtefact` fallback carries `type: string`, so it stays +// in every narrowed branch and breaks discriminated-union narrowing — the runtime +// `type` dispatch is authoritative (content is set alongside type in the +// normalizers), so we read `content` through the matching content type. + +/** Short, monospace location shown next to the type label (file path / span). */ +function locationLabel(artefact: AnySignalReportArtefact): string | null { + switch (artefact.type) { + case "code_reference": { + const c = artefact.content as CodeReferenceContent; + return `${c.file_path}:${c.start_line}-${c.end_line}`; + } + case "line_reference": { + const c = artefact.content as LineReferenceContent; + return `${c.file_path}:${c.line}`; + } + default: + return null; + } +} + +function CodeRefBlock({ code, language }: { code: string; language: string }) { + return ( + + + + + + ); +} + +function RelevanceNote({ note }: { note: string }) { + if (!note.trim()) return null; + return {note}; +} + +/** Judgment explanations are often paragraphs — collapsed by default behind a toggle. */ +function CollapsibleReasoning({ text }: { text: string }) { + const [expanded, setExpanded] = useState(false); + if (!text.trim()) return null; + return ( + + + {expanded ? ( + {text} + ) : null} + + ); +} + +/** + * Notes are free-form markdown and can run long — collapsed to a one-line + * preview (the first non-empty line) that expands to the rendered note. + */ +function CollapsibleNote({ + note, + author, +}: { + note: string; + author?: string | null; +}) { + const [expanded, setExpanded] = useState(false); + const preview = note + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0); + return ( + + + {expanded ? ( + + + {author ? ( + + — {author} + + ) : null} + + ) : null} + + ); +} + +function ReviewersBody({ reviewers }: { reviewers: SuggestedReviewer[] }) { + if (reviewers.length === 0) { + return ( + + No reviewers assigned. + + ); + } + return ( + + {reviewers.map((reviewer) => ( + + {reviewer.github_login ? ( + e.currentTarget.classList.add("loaded")} + /> + ) : null} + + {reviewer.user?.first_name ?? + reviewer.github_name ?? + reviewer.github_login} + + {reviewer.github_login ? ( + + @{reviewer.github_login} + + + ) : null} + + ))} + + ); +} + +function ArtefactBody({ + reportId, + artefact, +}: { + reportId: string; + artefact: AnySignalReportArtefact; +}) { + // Degraded rows carry a plain text preview instead of their type's content + // shape — render that rather than feeding mismatched content to a typed body. + if (artefact.degraded) { + const text = (artefact.content as SignalReportArtefactContent | null) + ?.content; + return ( + + {text || "No preview available."} + + ); + } + switch (artefact.type) { + case "code_reference": { + const c = artefact.content as CodeReferenceContent; + return ( + + + + + ); + } + case "line_reference": { + const c = artefact.content as LineReferenceContent; + return ( + + + {c.contents ? ( + + ) : null} + + ); + } + case "commit": + return ( + + ); + case "task_run": + return ( + + ); + case "note": { + const c = artefact.content as NoteContent; + return ; + } + case "priority_judgment": { + const c = artefact.content as PriorityJudgmentContent; + return ( + + + {c.explanation ? : null} + + ); + } + case "actionability_judgment": { + const c = artefact.content as ActionabilityJudgmentContent; + return ( + + + + {c.already_addressed ? ( + + Already addressed + + ) : null} + + {c.explanation ? : null} + + ); + } + case "safety_judgment": { + const c = artefact.content as SafetyJudgmentContent; + return ( + + + {c.choice ? "Safe to act on" : "Unsafe"} + + {c.explanation ? : null} + + ); + } + case "signal_finding": { + const c = artefact.content as SignalFindingContent; + return ( + + + + {c.signal_id} + + + {c.verified ? "Verified" : "Unverified"} + + + {c.relevant_code_paths.length > 0 ? ( + + {c.relevant_code_paths.map((path) => ( + + {path} + + ))} + + ) : null} + + ); + } + case "suggested_reviewers": + return ( + + ); + case "dismissal": { + const c = artefact.content as DismissalContent; + return ( + + + {prettify(c.reason)} + + {c.note ? : null} + + ); + } + default: { + const c = artefact.content as SignalReportArtefactContent | null; + const text = typeof c?.content === "string" ? c.content : ""; + return ( + + {text || "No preview available."} + + ); + } + } +} + +function ArtefactRow({ + reportId, + artefact, +}: { + reportId: string; + artefact: AnySignalReportArtefact; +}) { + const [showRaw, setShowRaw] = useState(false); + const location = locationLabel(artefact); + const attribution = attributionLabel(artefact); + + return ( + + + + + {typeLabel(artefact.type)} + + {location ? ( + + {location} + + ) : null} + + + {attribution ? ( + + by {attribution} + + ) : null} + {/* Dev-only escape hatch for inspecting the raw artefact payload. */} + {import.meta.env.DEV ? ( + + ) : null} + + + + + {showRaw ? ( +
+          {JSON.stringify(artefact, null, 2)}
+        
+ ) : null} +
+ ); +} + +export function ArtefactLogList({ + reportId, + artefacts, +}: { + reportId: string; + artefacts: AnySignalReportArtefact[]; +}) { + if (artefacts.length === 0) { + return null; + } + + const sorted = [...artefacts].sort( + (a, b) => + new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + ); + + return ( + + {sorted.map((artefact) => ( + + ))} + + ); +} diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx new file mode 100644 index 000000000..ed07a0d98 --- /dev/null +++ b/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx @@ -0,0 +1,94 @@ +import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react"; +import type { Task, TaskRunArtefactContent } from "@posthog/shared/types"; +import { TaskLogsPanel } from "@posthog/ui/features/task-detail/components/TaskLogsPanel"; +import { taskKeys } from "@posthog/ui/features/tasks/taskKeys"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { Badge, Box, Text } from "@radix-ui/themes"; +import { useState } from "react"; + +const SIGNALS_PRODUCT = "signals"; + +// Friendlier labels for the built-in signals-pipeline task types; custom-agent types fall back +// to a humanized form of their identifier. +const SIGNALS_TYPE_LABELS: Record = { + research: "Research", + implementation: "Implementation", + repo_selection: "Repo selection", +}; + +function humanizeIdentifier(value: string): string { + const spaced = value.replace(/[_-]+/g, " ").trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** + * Renders a `task_run` artefact: loads the referenced task and lets the user + * expand it to read the full conversation log (read-only) via `TaskLogsPanel`. + */ +export function ArtefactTaskRun({ + content, +}: { + content: TaskRunArtefactContent; +}) { + const [expanded, setExpanded] = useState(false); + + const taskQuery = useAuthenticatedQuery( + taskKeys.detail(content.task_id), + (client) => client.getTask(content.task_id), + { enabled: !!content.task_id, staleTime: 10_000 }, + ); + + const task = taskQuery.data; + const isSignals = content.product === SIGNALS_PRODUCT; + const label = isSignals + ? (SIGNALS_TYPE_LABELS[content.type] ?? humanizeIdentifier(content.type)) + : humanizeIdentifier(content.type); + const status = task?.latest_run?.status; + + return ( + + + + {taskQuery.isError ? ( + + Couldn’t load this task. + + ) : null} + + {expanded && task ? ( + + + + ) : null} + + ); +} diff --git a/packages/ui/src/features/inbox/components/detail/DiffBlock.tsx b/packages/ui/src/features/inbox/components/detail/DiffBlock.tsx new file mode 100644 index 000000000..728b1b252 --- /dev/null +++ b/packages/ui/src/features/inbox/components/detail/DiffBlock.tsx @@ -0,0 +1,27 @@ +// Renders unified-diff text with per-line +/- coloring, used by the `commit` +// artefact's commit-vs-parent diff view. +export function DiffBlock({ diff }: { diff: string }) { + const lines = diff.replace(/\n$/, "").split("\n"); + return ( +
+      {lines.map((line, i) => {
+        const added = line.startsWith("+") && !line.startsWith("+++");
+        const removed = line.startsWith("-") && !line.startsWith("---");
+        const hunk = line.startsWith("@@");
+        const cls = added
+          ? "bg-(--green-3) text-(--green-11)"
+          : removed
+            ? "bg-(--red-3) text-(--red-11)"
+            : hunk
+              ? "text-(--gray-10)"
+              : "text-(--gray-12)";
+        return (
+          // biome-ignore lint/suspicious/noArrayIndexKey: stable parse output, never reorders
+          
+ {line || " "} +
+ ); + })} +
+ ); +} diff --git a/packages/ui/src/features/inbox/components/detail/ReportActivitySection.tsx b/packages/ui/src/features/inbox/components/detail/ReportActivitySection.tsx new file mode 100644 index 000000000..09b966df8 --- /dev/null +++ b/packages/ui/src/features/inbox/components/detail/ReportActivitySection.tsx @@ -0,0 +1,38 @@ +import { ClockCounterClockwiseIcon } from "@phosphor-icons/react"; +import { ArtefactLogList } from "@posthog/ui/features/inbox/components/detail/ArtefactLogList"; +import { RightColumnSection } from "@posthog/ui/features/inbox/components/RightColumnSection"; +import { useInboxReportArtefacts } from "@posthog/ui/features/inbox/hooks/useInboxReports"; +import { Text } from "@radix-ui/themes"; + +/** + * The report's artefact log ("Activity"), shared by every report detail + * surface (reports, pull requests, runs) so the work log follows the report + * wherever it is rendered. Renders nothing while loading or when the report + * has no artefacts. + */ +export function ReportActivitySection({ reportId }: { reportId: string }) { + // The log is a live work record — agents append artefacts while the report is + // open, so don't let the app-wide 5-minute staleTime sit on it. Poll gently + // while mounted. + const { data: artefactsResp } = useInboxReportArtefacts(reportId, { + staleTime: 10_000, + refetchInterval: 20_000, + }); + const artefacts = artefactsResp?.results ?? []; + + if (artefacts.length === 0) return null; + + return ( + + {artefacts.length} entr{artefacts.length === 1 ? "y" : "ies"} +
+ } + > + + + ); +} diff --git a/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx b/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx index 0ba76e8af..225d93b13 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx +++ b/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx @@ -68,7 +68,7 @@ describe("useUpdateSuggestedReviewers", () => { vi.clearAllMocks(); }); - it("optimistically patches the suggested_reviewers artefact in the cache", async () => { + it("optimistically appends a new latest reviewers row, keeping the prior one", async () => { mockUpdateArtefact.mockResolvedValue(artefact([reviewer("octocat")])); const { result, queryClient } = renderUpdateHook(); @@ -92,12 +92,21 @@ describe("useUpdateSuggestedReviewers", () => { ]); const cached = queryClient.getQueryData(key); - const cachedArtefact = cached?.results.find((a) => a.id === ARTEFACT_ID) as - | SuggestedReviewersArtefact - | undefined; - expect(cachedArtefact?.content.map((r) => r.github_login)).toEqual([ + const reviewerRows = (cached?.results ?? []).filter( + (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", + ); + // The prior row is preserved untouched as history. + const priorRow = reviewerRows.find((a) => a.id === ARTEFACT_ID); + expect(priorRow?.content.map((r) => r.github_login)).toEqual([ "octocat", + "hubot", ]); + // A new synthetic row is appended and is the latest (current reviewers). + const latest = reviewerRows.reduce((a, b) => + a.created_at > b.created_at ? a : b, + ); + expect(latest.id).not.toBe(ARTEFACT_ID); + expect(latest.content.map((r) => r.github_login)).toEqual(["octocat"]); }); it("rolls back the cache when the request fails", async () => { diff --git a/packages/ui/src/features/inbox/hooks/useInboxReports.ts b/packages/ui/src/features/inbox/hooks/useInboxReports.ts index 1b2edb11e..97cf8b892 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxReports.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxReports.ts @@ -198,6 +198,7 @@ export function useInboxReportArtefacts( options?: { enabled?: boolean; staleTime?: number; + refetchInterval?: number; refetchOnWindowFocus?: boolean; }, ) { @@ -225,15 +226,17 @@ export function useInboxReportSignals( interface UpdateSuggestedReviewersVariables { artefactId: string; - /** Full-replacement payload sent to the server. */ + /** Reviewer list sent to the server (it appends a new suggested_reviewers status row). */ content: SuggestedReviewerWriteEntry[]; - /** Read-shape list used to optimistically patch the cache for immediate-apply UI. */ + /** Read-shape list used to optimistically show the new current reviewers. */ optimisticReviewers: SuggestedReviewersArtefact["content"]; } /** - * Persists a full replacement of a report's `suggested_reviewers` artefact and optimistically - * patches the cached artefacts so the detail pane reflects the change instantly (immediate apply). + * Edits a report's suggested reviewers. The server appends a new `suggested_reviewers` status + * artefact (latest-wins), so the work-log keeps the full history of changes. We optimistically + * append a synthetic latest row — mirroring the server — so the detail pane reflects the change + * instantly (immediate apply); the refetch on settle reconciles it with the real row. */ export function useUpdateSuggestedReviewers(reportId: string) { const queryClient = useQueryClient(); @@ -247,23 +250,25 @@ export function useUpdateSuggestedReviewers(reportId: string) { (client, { artefactId, content }) => client.updateSignalReportArtefact(reportId, artefactId, content), { - onMutate: async ({ artefactId, optimisticReviewers }) => { + onMutate: async ({ optimisticReviewers }) => { await queryClient.cancelQueries({ queryKey }); const previous = queryClient.getQueryData(queryKey); if (previous) { + // Append a synthetic latest row rather than mutating the current one — "current + // reviewers" is derived as the latest suggested_reviewers artefact, so a row stamped + // now wins, and the prior row stays in the log as history (matching the server). + const optimisticRow: SuggestedReviewersArtefact = { + id: `optimistic-${Date.now()}`, + type: "suggested_reviewers", + content: optimisticReviewers, + created_at: new Date().toISOString(), + }; queryClient.setQueryData(queryKey, { ...previous, - results: previous.results.map((artefact) => - artefact.id === artefactId && - artefact.type === "suggested_reviewers" - ? ({ - ...artefact, - content: optimisticReviewers, - } as SuggestedReviewersArtefact) - : artefact, - ), + results: [...previous.results, optimisticRow], + count: previous.count + 1, }); } diff --git a/packages/ui/src/features/inbox/hooks/useReportTasks.ts b/packages/ui/src/features/inbox/hooks/useReportTasks.ts index db4d4e37d..0d1aed216 100644 --- a/packages/ui/src/features/inbox/hooks/useReportTasks.ts +++ b/packages/ui/src/features/inbox/hooks/useReportTasks.ts @@ -1,20 +1,55 @@ import type { SignalReportStatus, - SignalReportTask, Task, + TaskRunArtefactContent, } from "@posthog/shared/types"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; -type Relationship = SignalReportTask["relationship"]; +// Task↔report associations are unlabelled — a task's purpose is derived from the report's +// `task_run` artefacts (the signals pipeline writes product="signals" with one of these types; +// custom agents write their own (product, type) pair). +export type ReportTaskPurpose = "research" | "implementation" | "other"; -const DISPLAYED_RELATIONSHIPS: Relationship[] = ["implementation", "research"]; - -interface ReportTaskData { +export interface ReportTaskData { task: Task; - relationship: Relationship; + purpose: ReportTaskPurpose; + /** Human-readable row label — "Research" / "Implementation" / a humanized custom pair. */ + purposeLabel: string; startedAt: string; } +function humanizeIdentifier(value: string): string { + const words = value.replace(/[_-]+/g, " ").trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +function derivePurpose(taskRun: { + product: string; + type: string; +}): { purpose: ReportTaskPurpose; purposeLabel: string } | null { + if (taskRun.product === "signals") { + if (taskRun.type === "research") { + return { purpose: "research", purposeLabel: "Research" }; + } + if (taskRun.type === "implementation") { + return { purpose: "implementation", purposeLabel: "Implementation" }; + } + // repo_selection runs are plumbing, not report work — never displayed (matches the + // pre-derivation behavior of only showing research/implementation). + return null; + } + return { + purpose: "other", + purposeLabel: `${humanizeIdentifier(taskRun.product)} — ${humanizeIdentifier(taskRun.type)}`, + }; +} + +const PURPOSE_ORDER: ReportTaskPurpose[] = [ + "implementation", + "research", + "other", +]; + export function useReportTasks( reportId: string, reportStatus: SignalReportStatus, @@ -27,24 +62,50 @@ export function useReportTasks( return useAuthenticatedQuery( ["inbox", "report-tasks", reportId], async (client) => { - const reportTasks = await client.getSignalReportTasks(reportId); - const relevant = reportTasks.filter((rt) => - DISPLAYED_RELATIONSHIPS.includes(rt.relationship), + // task_run artefacts ARE the task↔report association — one entry per associated task, + // keyed by content.task_id (earliest artefact wins for startedAt). The runtime `type` + // check is authoritative (the generic fallback artefact keeps `type: string` and + // defeats static narrowing). + const artefacts = await client.getSignalReportArtefacts(reportId); + const taskRunByTaskId = new Map< + string, + { product: string; type: string; startedAt: string } + >(); + for (const artefact of artefacts.results) { + if (artefact.type !== "task_run") continue; + const content = artefact.content as TaskRunArtefactContent; + const existing = taskRunByTaskId.get(content.task_id); + if (existing && existing.startedAt <= artefact.created_at) continue; + taskRunByTaskId.set(content.task_id, { + product: content.product, + type: content.type, + startedAt: artefact.created_at, + }); + } + + const relevant = [...taskRunByTaskId.entries()].flatMap( + ([taskId, run]) => { + const derived = derivePurpose(run); + return derived + ? [{ taskId, startedAt: run.startedAt, ...derived }] + : []; + }, ); + const tasks = await Promise.all( - relevant.map(async (rt) => { - const task = await client.getTask(rt.task_id); + relevant.map(async ({ taskId, startedAt, purpose, purposeLabel }) => { + const task = await client.getTask(taskId); return { task, - relationship: rt.relationship, - startedAt: rt.created_at, + purpose, + purposeLabel, + startedAt, }; }), ); return tasks.sort( (a, b) => - DISPLAYED_RELATIONSHIPS.indexOf(a.relationship) - - DISPLAYED_RELATIONSHIPS.indexOf(b.relationship), + PURPOSE_ORDER.indexOf(a.purpose) - PURPOSE_ORDER.indexOf(b.purpose), ); }, {