mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
[codex] Add runtime lifecycle recovery and live issue visibility (#4419)
This commit is contained in:
parent
9a8d219949
commit
5a0c1979cf
121 changed files with 9625 additions and 2044 deletions
|
|
@ -147,6 +147,10 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
|
|||
logBytes: null,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
lastOutputAt: null,
|
||||
lastOutputSeq: 0,
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: null,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
|
|
@ -837,6 +841,7 @@ describe("inbox helpers", () => {
|
|||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}).map((issue) => issue.id),
|
||||
|
|
@ -856,6 +861,7 @@ describe("inbox helpers", () => {
|
|||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}),
|
||||
|
|
@ -875,6 +881,7 @@ describe("inbox helpers", () => {
|
|||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}),
|
||||
|
|
@ -940,6 +947,7 @@ describe("inbox helpers", () => {
|
|||
labels: ["label-1"],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
liveOnly: true,
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -954,6 +962,7 @@ describe("inbox helpers", () => {
|
|||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -969,6 +978,7 @@ describe("inbox helpers", () => {
|
|||
labels: ["label-1"],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
liveOnly: true,
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -983,6 +993,7 @@ describe("inbox helpers", () => {
|
|||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -1000,6 +1011,7 @@ describe("inbox helpers", () => {
|
|||
labels: null,
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1", false],
|
||||
liveOnly: "yes",
|
||||
hideRoutineExecutions: "yes",
|
||||
},
|
||||
}));
|
||||
|
|
@ -1015,6 +1027,7 @@ describe("inbox helpers", () => {
|
|||
labels: [],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
liveOnly: false,
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -445,6 +445,7 @@ export function getInboxSearchSupplementIssues({
|
|||
issueFilters,
|
||||
currentUserId,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
liveIssueIds,
|
||||
}: {
|
||||
query: string;
|
||||
filteredWorkItems: InboxWorkItem[];
|
||||
|
|
@ -453,6 +454,7 @@ export function getInboxSearchSupplementIssues({
|
|||
issueFilters: IssueFilterState;
|
||||
currentUserId?: string | null;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
liveIssueIds?: ReadonlySet<string>;
|
||||
}): Issue[] {
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) return [];
|
||||
|
|
@ -462,7 +464,7 @@ export function getInboxSearchSupplementIssues({
|
|||
.map((item) => item.issue.id),
|
||||
...archivedSearchIssues.map((issue) => issue.id),
|
||||
]);
|
||||
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter)
|
||||
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter, liveIssueIds)
|
||||
.filter((issue) => !visibleIssueIds.has(issue.id));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface IssueChatComment extends IssueComment {
|
|||
queueState?: "queued";
|
||||
queueTargetRunId?: string | null;
|
||||
queueReason?: "hold" | "active_run" | "other";
|
||||
followUpRequested?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueChatLinkedRun {
|
||||
|
|
@ -43,6 +44,7 @@ export interface IssueChatLinkedRun {
|
|||
startedAt: Date | string | null;
|
||||
finishedAt?: Date | string | null;
|
||||
hasStoredOutput?: boolean;
|
||||
logBytes?: number | null;
|
||||
}
|
||||
|
||||
export interface IssueChatTranscriptEntry {
|
||||
|
|
@ -318,6 +320,7 @@ function createCommentMessage(args: {
|
|||
queueTargetRunId: comment.queueTargetRunId ?? null,
|
||||
queueReason: comment.queueReason ?? null,
|
||||
interruptedRunId: comment.interruptedRunId ?? null,
|
||||
followUpRequested: comment.followUpRequested === true,
|
||||
};
|
||||
|
||||
if (comment.authorAgentId) {
|
||||
|
|
@ -356,7 +359,9 @@ function createTimelineEventMessage(args: {
|
|||
? "System"
|
||||
: (formatAssigneeUserLabel(event.actorId, currentUserId, userLabelMap) ?? "Board");
|
||||
|
||||
const lines: string[] = [`${actorName} updated this issue`];
|
||||
const lines: string[] = [
|
||||
event.followUpRequested ? `${actorName} requested follow-up` : `${actorName} updated this issue`,
|
||||
];
|
||||
if (event.statusChange) {
|
||||
lines.push(
|
||||
`Status: ${event.statusChange.from ?? "none"} -> ${event.statusChange.to ?? "none"}`,
|
||||
|
|
@ -387,6 +392,7 @@ function createTimelineEventMessage(args: {
|
|||
actorId: event.actorId,
|
||||
statusChange: event.statusChange ?? null,
|
||||
assigneeChange: event.assigneeChange ?? null,
|
||||
followUpRequested: event.followUpRequested === true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState } from "./issue-filters";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
countActiveIssueFilters,
|
||||
defaultIssueFilterState,
|
||||
resolveIssueFilterWorkspaceId,
|
||||
shouldIncludeIssueFilterWorkspaceOption,
|
||||
} from "./issue-filters";
|
||||
|
||||
function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
|
|
@ -66,4 +72,100 @@ describe("issue filters", () => {
|
|||
creators: ["user:user-1"],
|
||||
})).toBe(1);
|
||||
});
|
||||
|
||||
it("filters issues to live issue ids when live-only is enabled", () => {
|
||||
const issues = [
|
||||
makeIssue({ id: "live-issue" }),
|
||||
makeIssue({ id: "idle-issue" }),
|
||||
];
|
||||
|
||||
const filtered = applyIssueFilters(
|
||||
issues,
|
||||
{ ...defaultIssueFilterState, liveOnly: true },
|
||||
null,
|
||||
false,
|
||||
new Set(["live-issue"]),
|
||||
);
|
||||
|
||||
expect(filtered.map((issue) => issue.id)).toEqual(["live-issue"]);
|
||||
});
|
||||
|
||||
it("counts the live-only filter as an active filter group", () => {
|
||||
expect(countActiveIssueFilters({
|
||||
...defaultIssueFilterState,
|
||||
liveOnly: true,
|
||||
})).toBe(1);
|
||||
});
|
||||
|
||||
it("does not treat default project workspaces as workspace filter matches", () => {
|
||||
const issue = makeIssue({
|
||||
id: "default-workspace-issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-default",
|
||||
});
|
||||
const workspaceContext = {
|
||||
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]),
|
||||
};
|
||||
|
||||
expect(resolveIssueFilterWorkspaceId(issue, workspaceContext)).toBeNull();
|
||||
expect(applyIssueFilters(
|
||||
[issue],
|
||||
{ ...defaultIssueFilterState, workspaces: ["workspace-default"] },
|
||||
null,
|
||||
false,
|
||||
undefined,
|
||||
workspaceContext,
|
||||
)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat shared default execution workspaces as workspace filter matches", () => {
|
||||
const issue = makeIssue({
|
||||
id: "shared-default-issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-default",
|
||||
executionWorkspaceId: "execution-shared-default",
|
||||
});
|
||||
const workspaceContext = {
|
||||
executionWorkspaceById: new Map([[
|
||||
"execution-shared-default",
|
||||
{ mode: "shared_workspace", projectWorkspaceId: "workspace-default" },
|
||||
]]),
|
||||
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]),
|
||||
};
|
||||
|
||||
expect(resolveIssueFilterWorkspaceId(issue, workspaceContext)).toBeNull();
|
||||
expect(shouldIncludeIssueFilterWorkspaceOption(
|
||||
{ id: "execution-shared-default", mode: "shared_workspace", projectWorkspaceId: "workspace-default" },
|
||||
new Set(["workspace-default"]),
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps non-default project and isolated execution workspaces filterable", () => {
|
||||
const featureIssue = makeIssue({
|
||||
id: "feature-issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-feature",
|
||||
});
|
||||
const executionIssue = makeIssue({
|
||||
id: "execution-issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-default",
|
||||
executionWorkspaceId: "execution-isolated",
|
||||
});
|
||||
const workspaceContext = {
|
||||
executionWorkspaceById: new Map([[
|
||||
"execution-isolated",
|
||||
{ mode: "isolated_workspace", projectWorkspaceId: "workspace-default" },
|
||||
]]),
|
||||
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]),
|
||||
};
|
||||
|
||||
expect(resolveIssueFilterWorkspaceId(featureIssue, workspaceContext)).toBe("workspace-feature");
|
||||
expect(resolveIssueFilterWorkspaceId(executionIssue, workspaceContext)).toBe("execution-isolated");
|
||||
expect(shouldIncludeIssueFilterWorkspaceOption({ id: "workspace-feature" }, new Set(["workspace-default"]))).toBe(true);
|
||||
expect(shouldIncludeIssueFilterWorkspaceOption(
|
||||
{ id: "execution-isolated", mode: "isolated_workspace", projectWorkspaceId: "workspace-default" },
|
||||
new Set(["workspace-default"]),
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
export type IssueFilterWorkspaceLookup = {
|
||||
mode?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
};
|
||||
|
||||
export type IssueFilterWorkspaceContext = {
|
||||
executionWorkspaceById?: ReadonlyMap<string, IssueFilterWorkspaceLookup>;
|
||||
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
|
||||
};
|
||||
|
||||
export type IssueFilterState = {
|
||||
statuses: string[];
|
||||
priorities: string[];
|
||||
|
|
@ -8,6 +18,7 @@ export type IssueFilterState = {
|
|||
labels: string[];
|
||||
projects: string[];
|
||||
workspaces: string[];
|
||||
liveOnly?: boolean;
|
||||
hideRoutineExecutions: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -19,6 +30,7 @@ export const defaultIssueFilterState: IssueFilterState = {
|
|||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
hideRoutineExecutions: false,
|
||||
};
|
||||
|
||||
|
|
@ -59,6 +71,7 @@ export function normalizeIssueFilterState(value: unknown): IssueFilterState {
|
|||
labels: normalizeIssueFilterValueArray(candidate.labels),
|
||||
projects: normalizeIssueFilterValueArray(candidate.projects),
|
||||
workspaces: normalizeIssueFilterValueArray(candidate.workspaces),
|
||||
liveOnly: candidate.liveOnly === true,
|
||||
hideRoutineExecutions: candidate.hideRoutineExecutions === true,
|
||||
};
|
||||
}
|
||||
|
|
@ -68,9 +81,41 @@ export function toggleIssueFilterValue(values: string[], value: string): string[
|
|||
}
|
||||
|
||||
export function resolveIssueFilterWorkspaceId(
|
||||
issue: Pick<Issue, "executionWorkspaceId" | "projectWorkspaceId">,
|
||||
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
|
||||
context: IssueFilterWorkspaceContext = {},
|
||||
): string | null {
|
||||
return issue.executionWorkspaceId ?? issue.projectWorkspaceId ?? null;
|
||||
const defaultProjectWorkspaceId = issue.projectId
|
||||
? context.defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null
|
||||
: null;
|
||||
|
||||
if (issue.executionWorkspaceId) {
|
||||
const executionWorkspace = context.executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null;
|
||||
const linkedProjectWorkspaceId =
|
||||
executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null;
|
||||
const isDefaultSharedExecutionWorkspace =
|
||||
executionWorkspace?.mode === "shared_workspace"
|
||||
&& linkedProjectWorkspaceId != null
|
||||
&& linkedProjectWorkspaceId === defaultProjectWorkspaceId;
|
||||
if (isDefaultSharedExecutionWorkspace) return null;
|
||||
return issue.executionWorkspaceId;
|
||||
}
|
||||
|
||||
if (issue.projectWorkspaceId) {
|
||||
if (issue.projectWorkspaceId === defaultProjectWorkspaceId) return null;
|
||||
return issue.projectWorkspaceId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldIncludeIssueFilterWorkspaceOption(
|
||||
workspace: { id: string; mode?: string | null; projectWorkspaceId?: string | null },
|
||||
defaultProjectWorkspaceIds: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (defaultProjectWorkspaceIds.has(workspace.id)) return false;
|
||||
return !(workspace.mode === "shared_workspace"
|
||||
&& workspace.projectWorkspaceId != null
|
||||
&& defaultProjectWorkspaceIds.has(workspace.projectWorkspaceId));
|
||||
}
|
||||
|
||||
export function applyIssueFilters(
|
||||
|
|
@ -78,8 +123,13 @@ export function applyIssueFilters(
|
|||
state: IssueFilterState,
|
||||
currentUserId?: string | null,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
liveIssueIds?: ReadonlySet<string>,
|
||||
workspaceContext: IssueFilterWorkspaceContext = {},
|
||||
): Issue[] {
|
||||
let result = issues;
|
||||
if (state.liveOnly) {
|
||||
result = result.filter((issue) => liveIssueIds?.has(issue.id) === true);
|
||||
}
|
||||
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) {
|
||||
result = result.filter((issue) => issue.originKind !== "routine_execution");
|
||||
}
|
||||
|
|
@ -112,7 +162,7 @@ export function applyIssueFilters(
|
|||
}
|
||||
if (state.workspaces.length > 0) {
|
||||
result = result.filter((issue) => {
|
||||
const workspaceId = resolveIssueFilterWorkspaceId(issue);
|
||||
const workspaceId = resolveIssueFilterWorkspaceId(issue, workspaceContext);
|
||||
return workspaceId != null && state.workspaces.includes(workspaceId);
|
||||
});
|
||||
}
|
||||
|
|
@ -131,6 +181,7 @@ export function countActiveIssueFilters(
|
|||
if (state.labels.length > 0) count += 1;
|
||||
if (state.projects.length > 0) count += 1;
|
||||
if (state.workspaces.length > 0) count += 1;
|
||||
if (state.liveOnly) count += 1;
|
||||
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,80 @@ describe("extractIssueTimelineEvents", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("marks explicit follow-up timeline updates", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
id: "evt-follow-up",
|
||||
companyId: "company-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
details: {
|
||||
status: "todo",
|
||||
reopened: true,
|
||||
reopenedFrom: "done",
|
||||
source: "comment",
|
||||
commentId: "comment-1",
|
||||
resumeIntent: true,
|
||||
followUpRequested: true,
|
||||
},
|
||||
},
|
||||
] satisfies ActivityEvent[]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
id: "evt-follow-up",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
commentId: "comment-1",
|
||||
followUpRequested: true,
|
||||
statusChange: {
|
||||
from: "done",
|
||||
to: "todo",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("synthesizes non-status follow-up rows from comment activity", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
id: "evt-comment-follow-up",
|
||||
companyId: "company-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
action: "issue.comment_added",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
details: {
|
||||
commentId: "comment-1",
|
||||
resumeIntent: true,
|
||||
followUpRequested: true,
|
||||
},
|
||||
},
|
||||
] satisfies ActivityEvent[]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
id: "evt-comment-follow-up",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
commentId: "comment-1",
|
||||
followUpRequested: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores issue updates without visible status or assignee transitions", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export interface IssueTimelineEvent {
|
|||
from: IssueTimelineAssignee;
|
||||
to: IssueTimelineAssignee;
|
||||
};
|
||||
commentId?: string | null;
|
||||
followUpRequested?: boolean;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
|
|
@ -53,11 +55,26 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
|
|||
const events: IssueTimelineEvent[] = [];
|
||||
|
||||
for (const event of activity ?? []) {
|
||||
if (event.action !== "issue.updated") continue;
|
||||
|
||||
const details = asRecord(event.details);
|
||||
if (!details) continue;
|
||||
|
||||
if (event.action === "issue.comment_added") {
|
||||
if (details.followUpRequested !== true && details.resumeIntent !== true) continue;
|
||||
if (details.reopened === true) continue;
|
||||
const commentId = nullableString(details.commentId);
|
||||
events.push({
|
||||
id: event.id,
|
||||
createdAt: event.createdAt,
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
commentId,
|
||||
followUpRequested: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.action !== "issue.updated") continue;
|
||||
|
||||
const previous = asRecord(details._previous);
|
||||
const timelineEvent: IssueTimelineEvent = {
|
||||
id: event.id,
|
||||
|
|
@ -65,6 +82,10 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
|
|||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
};
|
||||
if (details.followUpRequested === true || details.resumeIntent === true) {
|
||||
timelineEvent.followUpRequested = true;
|
||||
timelineEvent.commentId = nullableString(details.commentId);
|
||||
}
|
||||
|
||||
if (hasOwn(details, "status")) {
|
||||
const from = nullableString(previous?.status) ?? nullableString(details.reopenedFrom);
|
||||
|
|
@ -96,7 +117,7 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
|
|||
}
|
||||
}
|
||||
|
||||
if (timelineEvent.statusChange || timelineEvent.assigneeChange) {
|
||||
if (timelineEvent.statusChange || timelineEvent.assigneeChange || timelineEvent.followUpRequested) {
|
||||
events.push(timelineEvent);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export function resolveIssueChatTranscriptRuns(args: {
|
|||
id: run.id,
|
||||
status: run.status,
|
||||
adapterType: run.adapterType,
|
||||
logBytes: run.logBytes,
|
||||
lastOutputBytes: run.lastOutputBytes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -23,6 +25,8 @@ export function resolveIssueChatTranscriptRuns(args: {
|
|||
id: activeRun.id,
|
||||
status: activeRun.status,
|
||||
adapterType: activeRun.adapterType,
|
||||
logBytes: activeRun.logBytes,
|
||||
lastOutputBytes: activeRun.lastOutputBytes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +39,7 @@ export function resolveIssueChatTranscriptRuns(args: {
|
|||
status: run.status,
|
||||
adapterType,
|
||||
hasStoredOutput: run.hasStoredOutput,
|
||||
logBytes: run.logBytes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type IssueDetailHeaderSeed = {
|
|||
identifier: string | null;
|
||||
title: string;
|
||||
status: Issue["status"];
|
||||
blockerAttention?: Issue["blockerAttention"];
|
||||
priority: Issue["priority"];
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
|
|
@ -47,11 +48,15 @@ function isIssueDetailHeaderSeed(value: unknown): value is IssueDetailHeaderSeed
|
|||
candidate.originKind === undefined || typeof candidate.originKind === "string";
|
||||
const hasOriginId =
|
||||
candidate.originId === undefined || candidate.originId === null || typeof candidate.originId === "string";
|
||||
const hasBlockerAttention =
|
||||
candidate.blockerAttention === undefined
|
||||
|| (typeof candidate.blockerAttention === "object" && candidate.blockerAttention !== null);
|
||||
return (
|
||||
typeof candidate.id === "string"
|
||||
&& (candidate.identifier === null || typeof candidate.identifier === "string")
|
||||
&& typeof candidate.title === "string"
|
||||
&& typeof candidate.status === "string"
|
||||
&& hasBlockerAttention
|
||||
&& typeof candidate.priority === "string"
|
||||
&& (candidate.projectId === null || typeof candidate.projectId === "string")
|
||||
&& (candidate.projectName === null || typeof candidate.projectName === "string")
|
||||
|
|
@ -66,6 +71,7 @@ function createIssueDetailHeaderSeed(issue: Issue): IssueDetailHeaderSeed {
|
|||
identifier: issue.identifier ?? null,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
blockerAttention: issue.blockerAttention,
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId ?? null,
|
||||
projectName: issue.project?.name ?? null,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,19 @@ describe("collectLiveIssueIds", () => {
|
|||
adapterType: "codex_local",
|
||||
issueId: "issue-2",
|
||||
},
|
||||
{
|
||||
id: "run-5",
|
||||
status: "succeeded",
|
||||
invocationSource: "scheduler",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-04-20T10:04:00.000Z",
|
||||
finishedAt: "2026-04-20T10:05:00.000Z",
|
||||
createdAt: "2026-04-20T10:04:00.000Z",
|
||||
agentId: "agent-5",
|
||||
agentName: "Done",
|
||||
adapterType: "codex_local",
|
||||
issueId: "completed-issue",
|
||||
},
|
||||
];
|
||||
|
||||
expect([...collectLiveIssueIds(liveRuns)]).toEqual(["issue-1", "issue-2"]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||
|
||||
function isLiveRunStatus(status: string): boolean {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
export function collectLiveIssueIds(liveRuns: readonly LiveRunForIssue[] | null | undefined): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const run of liveRuns ?? []) {
|
||||
if (run.issueId) ids.add(run.issueId);
|
||||
if (run.issueId && isLiveRunStatus(run.status)) ids.add(run.issueId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ describe("runRetryState", () => {
|
|||
).toMatchObject({
|
||||
kind: "exhausted",
|
||||
badgeLabel: "Retry exhausted",
|
||||
detail: "Attempt 4 · Transient failure · No further automatic retry queued",
|
||||
detail: "Attempt 4 · Transient failure · Automatic retries exhausted",
|
||||
secondary: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued Manual intervention required.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,8 +76,10 @@ export function describeRunRetryState(run: RetryAwareRun): RunRetryStateSummary
|
|||
kind: "exhausted",
|
||||
badgeLabel: "Retry exhausted",
|
||||
tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
detail: joinFragments([attemptLabel, reasonLabel, "No further automatic retry queued"]),
|
||||
secondary: exhaustedReason,
|
||||
detail: joinFragments([attemptLabel, reasonLabel, "Automatic retries exhausted"]),
|
||||
secondary: exhaustedReason.includes("Manual intervention required")
|
||||
? exhaustedReason
|
||||
: `${exhaustedReason} Manual intervention required.`,
|
||||
retryOfRunId,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue