mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Skills Manager gives operators a reusable skill layer, but iteration still required manual edits, ad hoc prompts, and indirect run inspection. > - Skill authors need a focused workflow for editing skill files, saving representative test inputs, and running those inputs through an agent without exposing harness tasks as normal company work. > - The backend therefore needs durable test inputs, reusable run templates, hidden harness issues, scoped run execution, retention metadata, and read-containment rules around hidden work. > - The frontend needs a three-pane Studio that keeps skill files, saved inputs/templates, and run output/history visible together while preserving the existing design system and token rules. > - This pull request ships that Skill Studio surface end to end: database migrations, shared contracts, server APIs/services, hidden harness execution behavior, UI routes/components, and focused tests. > - The benefit is faster and safer skill iteration, with inspectable outputs and fewer ways for internal harness work to leak into normal task lists, costs, or adjacent read APIs. ## Linked Issues or Issue Description No public GitHub issue exists for this feature. Feature request summary: - Problem: Skill authors need to edit and test company skills in one place instead of switching between the skill detail page, task creation, run output, and manual prompt history. - Proposed solution: Add a Skill Studio workbench with saved inputs, reusable templates, hidden sandboxed test runs, live run status, output inspection, run history, rerun/delete controls, and frontmatter-aware editing. - Expected users: Paperclip operators and agent-company maintainers who create, fork, import, and tune skills. - Related public PRs: Supersedes #9205, which was replaced so the public PR branch name follows contributor policy. - Duplicate search: searched public GitHub issues and PRs for "Skill Studio"; no other active public issue or PR directly covers this feature. ## What Changed - Added database migrations for Skill Studio test inputs, test runs, test run retention, and reusable run templates. - Added shared Skill Studio types, validators, route helpers, frontmatter utilities, and status handling. - Added server services and routes for saved inputs, test runs, templates, reruns, terminal-run deletion, hidden harness issue execution, and run-detail hydration. - Strengthened hidden-issue read containment across issue-adjacent routes and cost rollups used by skill test harness work. - Added the Skill Studio UI with skill file editing, frontmatter editing, saved inputs, templates, run creation/cancel/rerun/delete flows, output rendering, history, route support, and responsive pane behavior. - Added focused backend, shared, and UI tests for the new APIs, routing logic, editor/run behavior, hidden-issue containment, and migration safety. - Rebased onto current `master`, removed the generated lockfile diff from the PR, and verified no workflow files are changed. ## Verification - [x] `pnpm --filter @paperclipai/db check:migrations` - [x] `pnpm check:token-gates` - [x] `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/company-skills-routes.test.ts server/src/__tests__/company-skill-test-runs-service.test.ts ui/src/lib/skill-studio.test.ts ui/src/pages/SkillStudio.test.tsx` — 5 files, 132 tests passed - [x] Greptile review on the latest PR head - [x] GitHub PR checks on the latest PR head ## Risks - Medium risk because this is a broad feature touching database schema, server orchestration, issue visibility, and a large UI surface. - Hidden harness issue containment is security-sensitive; this PR includes regression coverage for adjacent read paths and cost rollups. - The new migrations are additive and use idempotent guards where applicable, but deployed databases that previously tested draft migration numbers should still be checked carefully. - The UI depends on a new resizable panels package in `ui/package.json`; the lockfile is intentionally left to repository automation. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 coding agent with shell, git, and GitHub CLI tool use. Earlier feature commits include assistance from other Paperclip coding agents; this PR preparation, rebase, cleanup commit, and PR body were completed by OpenAI Codex in a Paperclip worktree. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
792 lines
27 KiB
TypeScript
792 lines
27 KiB
TypeScript
import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
approvals,
|
|
authUsers,
|
|
heartbeatRuns,
|
|
issueApprovals,
|
|
issueComments,
|
|
issues,
|
|
issueThreadInteractions,
|
|
} from "@paperclipai/db";
|
|
import { visibleIssueCondition } from "./issue-visibility.js";
|
|
|
|
// DTO types are shared with the UI via @paperclipai/shared so both sides consume
|
|
// one contract. Re-exported here for back-compat with existing server imports.
|
|
import type {
|
|
TimelineActorType,
|
|
WorkTimelineActor,
|
|
WorkTimelineSpan,
|
|
WorkTimelineEvent,
|
|
WorkTimelineEdge,
|
|
WorkTimelineResult,
|
|
} from "@paperclipai/shared";
|
|
|
|
export type {
|
|
TimelineActorType,
|
|
TimelineEventKind,
|
|
TimelineEdgeKind,
|
|
WorkTimelineActor,
|
|
WorkTimelineSpan,
|
|
WorkTimelineEvent,
|
|
WorkTimelineEdge,
|
|
WorkTimelineResult,
|
|
} from "@paperclipai/shared";
|
|
|
|
export interface WorkTimelineQuery {
|
|
companyId: string;
|
|
from?: Date;
|
|
to?: Date;
|
|
userId?: string;
|
|
goalId?: string;
|
|
projectId?: string;
|
|
issueId?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
canReadIssue?: (issue: WorkTimelineIssueAccessInput) => Promise<boolean>;
|
|
}
|
|
|
|
export interface WorkTimelineIssueAccessInput {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
parentId: string | null;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
status: string;
|
|
}
|
|
|
|
type IssueRow = {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
goalId: string | null;
|
|
parentId: string | null;
|
|
identifier: string | null;
|
|
title: string | null;
|
|
createdByAgentId: string | null;
|
|
createdByUserId: string | null;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
status: string;
|
|
createdAt: Date;
|
|
};
|
|
|
|
type RunUsage = NonNullable<WorkTimelineSpan["usage"]>;
|
|
|
|
const DEFAULT_LIMIT = 200;
|
|
const MAX_LIMIT = 500;
|
|
const MAX_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
|
|
const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
const MAX_SOURCE_ROWS = 5_000;
|
|
const ACL_FILTER_CONCURRENCY = 16;
|
|
|
|
function actorId(type: TimelineActorType, id: string) {
|
|
return `${type}:${id}`;
|
|
}
|
|
|
|
function normalizeLimit(value: number | undefined) {
|
|
if (!Number.isFinite(value)) return DEFAULT_LIMIT;
|
|
return Math.max(1, Math.min(MAX_LIMIT, Math.floor(value ?? DEFAULT_LIMIT)));
|
|
}
|
|
|
|
function normalizeOffset(value: number | undefined) {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.max(0, Math.floor(value ?? 0));
|
|
}
|
|
|
|
export function normalizeTimelineWindow(input: { from?: Date; to?: Date }, now = new Date()) {
|
|
const rawTo = input.to ?? now;
|
|
const to = rawTo.getTime() > now.getTime() ? now : rawTo;
|
|
const requestedFrom = input.from ?? new Date(to.getTime() - DEFAULT_WINDOW_MS);
|
|
let from = requestedFrom;
|
|
let capped = false;
|
|
if (to.getTime() - from.getTime() > MAX_WINDOW_MS) {
|
|
from = new Date(to.getTime() - MAX_WINDOW_MS);
|
|
capped = true;
|
|
}
|
|
if (from.getTime() > to.getTime()) {
|
|
from = new Date(to.getTime() - DEFAULT_WINDOW_MS);
|
|
capped = true;
|
|
}
|
|
return { from, to, capped };
|
|
}
|
|
|
|
function dateIso(value: Date | null | undefined) {
|
|
return value ? value.toISOString() : null;
|
|
}
|
|
|
|
function readString(value: unknown) {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function readNumber(value: unknown) {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readUsageToken(source: Record<string, unknown>, ...keys: string[]) {
|
|
for (const key of keys) {
|
|
const value = readNumber(source[key]);
|
|
if (value != null) return Math.max(0, Math.floor(value));
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function normalizeRunUsage(usageJson: unknown): RunUsage | null {
|
|
if (!usageJson || typeof usageJson !== "object" || Array.isArray(usageJson)) return null;
|
|
const source = usageJson as Record<string, unknown>;
|
|
const inputTokens = readUsageToken(source, "inputTokens", "input_tokens", "rawInputTokens", "raw_input_tokens");
|
|
const cachedInputTokens = readUsageToken(
|
|
source,
|
|
"cachedInputTokens",
|
|
"cached_input_tokens",
|
|
"cacheReadInputTokens",
|
|
"cache_read_input_tokens",
|
|
);
|
|
const outputTokens = readUsageToken(source, "outputTokens", "output_tokens", "rawOutputTokens", "raw_output_tokens");
|
|
const totalTokens = inputTokens + cachedInputTokens + outputTokens;
|
|
return totalTokens > 0 ? { inputTokens, cachedInputTokens, outputTokens, totalTokens } : null;
|
|
}
|
|
|
|
function maybeUuidList(ids: Iterable<string>) {
|
|
return Array.from(new Set(Array.from(ids).filter((id) => id.length > 0)));
|
|
}
|
|
|
|
function runOverlapsWindow(from: Date, to: Date) {
|
|
const fromIso = from.toISOString();
|
|
const toIso = to.toISOString();
|
|
return and(
|
|
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${toIso}::timestamptz`,
|
|
sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) >= ${fromIso}::timestamptz`,
|
|
);
|
|
}
|
|
|
|
export function workTimelineService(db: Db) {
|
|
async function filterReadableIssues(
|
|
rows: IssueRow[],
|
|
canReadIssue: NonNullable<WorkTimelineQuery["canReadIssue"]> | undefined,
|
|
) {
|
|
if (!canReadIssue) return rows;
|
|
|
|
const allowedRows: IssueRow[] = [];
|
|
for (let index = 0; index < rows.length; index += ACL_FILTER_CONCURRENCY) {
|
|
const batch = rows.slice(index, index + ACL_FILTER_CONCURRENCY);
|
|
const decisions = await Promise.all(batch.map(async (issue) => ({
|
|
issue,
|
|
allowed: await canReadIssue({
|
|
id: issue.id,
|
|
companyId: issue.companyId,
|
|
projectId: issue.projectId,
|
|
parentId: issue.parentId,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
status: issue.status,
|
|
}),
|
|
})));
|
|
for (const decision of decisions) {
|
|
if (decision.allowed) allowedRows.push(decision.issue);
|
|
}
|
|
}
|
|
return allowedRows;
|
|
}
|
|
|
|
async function collectIssueIds(input: WorkTimelineQuery, from: Date, to: Date) {
|
|
const ids = new Set<string>();
|
|
|
|
if (input.issueId) {
|
|
ids.add(input.issueId);
|
|
}
|
|
|
|
const filterConditions = [
|
|
eq(issues.companyId, input.companyId),
|
|
visibleIssueCondition(),
|
|
input.goalId ? eq(issues.goalId, input.goalId) : undefined,
|
|
input.projectId ? eq(issues.projectId, input.projectId) : undefined,
|
|
input.issueId ? eq(issues.id, input.issueId) : undefined,
|
|
].filter(Boolean);
|
|
|
|
const recentlyTouched = await db
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
...filterConditions,
|
|
or(
|
|
and(gte(issues.createdAt, from), lte(issues.createdAt, to)),
|
|
and(gte(issues.updatedAt, from), lte(issues.updatedAt, to)),
|
|
),
|
|
),
|
|
)
|
|
.orderBy(desc(issues.updatedAt))
|
|
.limit(MAX_SOURCE_ROWS);
|
|
for (const row of recentlyTouched) ids.add(row.id);
|
|
|
|
const runContextRows = await db
|
|
.select({ issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'` })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
runOverlapsWindow(from, to),
|
|
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' is not null`,
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRuns.createdAt))
|
|
.limit(MAX_SOURCE_ROWS);
|
|
for (const row of runContextRows) {
|
|
if (row.issueId) ids.add(row.issueId);
|
|
}
|
|
|
|
const activityIssueRows = await db
|
|
.select({ issueId: activityLog.entityId })
|
|
.from(activityLog)
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, input.companyId),
|
|
eq(activityLog.entityType, "issue"),
|
|
gte(activityLog.createdAt, from),
|
|
lte(activityLog.createdAt, to),
|
|
),
|
|
)
|
|
.orderBy(desc(activityLog.createdAt))
|
|
.limit(MAX_SOURCE_ROWS);
|
|
for (const row of activityIssueRows) ids.add(row.issueId);
|
|
|
|
const commentIssueRows = await db
|
|
.select({ issueId: issueComments.issueId })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, input.companyId),
|
|
isNull(issueComments.deletedAt),
|
|
gte(issueComments.createdAt, from),
|
|
lte(issueComments.createdAt, to),
|
|
),
|
|
)
|
|
.orderBy(desc(issueComments.createdAt))
|
|
.limit(MAX_SOURCE_ROWS);
|
|
for (const row of commentIssueRows) ids.add(row.issueId);
|
|
|
|
const interactionIssueRows = await db
|
|
.select({ issueId: issueThreadInteractions.issueId })
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, input.companyId),
|
|
or(
|
|
and(gte(issueThreadInteractions.createdAt, from), lte(issueThreadInteractions.createdAt, to)),
|
|
and(gte(issueThreadInteractions.resolvedAt, from), lte(issueThreadInteractions.resolvedAt, to)),
|
|
),
|
|
),
|
|
)
|
|
.orderBy(desc(issueThreadInteractions.createdAt))
|
|
.limit(MAX_SOURCE_ROWS);
|
|
for (const row of interactionIssueRows) ids.add(row.issueId);
|
|
|
|
const approvalIssueRows = await db
|
|
.select({ issueId: issueApprovals.issueId })
|
|
.from(issueApprovals)
|
|
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
|
.where(
|
|
and(
|
|
eq(issueApprovals.companyId, input.companyId),
|
|
or(
|
|
and(gte(approvals.createdAt, from), lte(approvals.createdAt, to)),
|
|
and(gte(approvals.decidedAt, from), lte(approvals.decidedAt, to)),
|
|
),
|
|
),
|
|
)
|
|
.orderBy(desc(approvals.createdAt))
|
|
.limit(MAX_SOURCE_ROWS);
|
|
for (const row of approvalIssueRows) ids.add(row.issueId);
|
|
|
|
return maybeUuidList(ids);
|
|
}
|
|
|
|
async function loadIssues(input: WorkTimelineQuery, issueIds: string[]) {
|
|
if (issueIds.length === 0) return [];
|
|
return db
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
projectId: issues.projectId,
|
|
goalId: issues.goalId,
|
|
parentId: issues.parentId,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
createdByAgentId: issues.createdByAgentId,
|
|
createdByUserId: issues.createdByUserId,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
status: issues.status,
|
|
createdAt: issues.createdAt,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, input.companyId),
|
|
visibleIssueCondition(),
|
|
inArray(issues.id, issueIds),
|
|
input.goalId ? eq(issues.goalId, input.goalId) : undefined,
|
|
input.projectId ? eq(issues.projectId, input.projectId) : undefined,
|
|
input.issueId ? eq(issues.id, input.issueId) : undefined,
|
|
),
|
|
);
|
|
}
|
|
|
|
async function applyUserLens(input: WorkTimelineQuery, rows: IssueRow[], from: Date, to: Date) {
|
|
if (!input.userId) return rows;
|
|
|
|
const byId = new Map(rows.map((issue) => [issue.id, issue]));
|
|
const selected = new Set<string>();
|
|
for (const issue of rows) {
|
|
if (issue.createdByUserId === input.userId || issue.assigneeUserId === input.userId) selected.add(issue.id);
|
|
}
|
|
|
|
const [commentRows, approvalRows, interactionRows, activityRows] = await Promise.all([
|
|
db
|
|
.select({ issueId: issueComments.issueId })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, input.companyId),
|
|
eq(issueComments.authorUserId, input.userId),
|
|
isNull(issueComments.deletedAt),
|
|
gte(issueComments.createdAt, from),
|
|
lte(issueComments.createdAt, to),
|
|
),
|
|
),
|
|
db
|
|
.select({ issueId: issueApprovals.issueId })
|
|
.from(issueApprovals)
|
|
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
|
.where(
|
|
and(
|
|
eq(issueApprovals.companyId, input.companyId),
|
|
eq(approvals.decidedByUserId, input.userId),
|
|
gte(approvals.decidedAt, from),
|
|
lte(approvals.decidedAt, to),
|
|
),
|
|
),
|
|
db
|
|
.select({ issueId: issueThreadInteractions.issueId })
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, input.companyId),
|
|
eq(issueThreadInteractions.resolvedByUserId, input.userId),
|
|
gte(issueThreadInteractions.resolvedAt, from),
|
|
lte(issueThreadInteractions.resolvedAt, to),
|
|
),
|
|
),
|
|
db
|
|
.select({ issueId: activityLog.entityId })
|
|
.from(activityLog)
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, input.companyId),
|
|
eq(activityLog.actorType, "user"),
|
|
eq(activityLog.actorId, input.userId),
|
|
eq(activityLog.entityType, "issue"),
|
|
gte(activityLog.createdAt, from),
|
|
lte(activityLog.createdAt, to),
|
|
),
|
|
),
|
|
]);
|
|
|
|
for (const row of [...commentRows, ...approvalRows, ...interactionRows, ...activityRows]) {
|
|
selected.add(row.issueId);
|
|
}
|
|
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const issue of rows) {
|
|
if (issue.parentId && selected.has(issue.parentId) && !selected.has(issue.id)) {
|
|
selected.add(issue.id);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return rows.filter((issue) => selected.has(issue.id) || byId.get(issue.parentId ?? "") && selected.has(issue.parentId ?? ""));
|
|
}
|
|
|
|
async function loadActorMaps(companyId: string, actorIds: Set<string>) {
|
|
const agentIds = Array.from(actorIds)
|
|
.filter((id) => id.startsWith("agent:"))
|
|
.map((id) => id.slice("agent:".length));
|
|
const userIds = Array.from(actorIds)
|
|
.filter((id) => id.startsWith("user:"))
|
|
.map((id) => id.slice("user:".length));
|
|
|
|
const [agentRows, userRows] = await Promise.all([
|
|
agentIds.length > 0
|
|
? db
|
|
.select({ id: agents.id, name: agents.name, icon: agents.icon })
|
|
.from(agents)
|
|
.where(and(eq(agents.companyId, companyId), inArray(agents.id, maybeUuidList(agentIds))))
|
|
: [],
|
|
userIds.length > 0
|
|
? db
|
|
.select({ id: authUsers.id, name: authUsers.name, image: authUsers.image })
|
|
.from(authUsers)
|
|
.where(inArray(authUsers.id, maybeUuidList(userIds)))
|
|
: [],
|
|
]);
|
|
|
|
return {
|
|
agents: new Map(agentRows.map((agent) => [agent.id, agent])),
|
|
users: new Map(userRows.map((user) => [user.id, user])),
|
|
};
|
|
}
|
|
|
|
function actorForIssueCreator(issue: IssueRow) {
|
|
if (issue.createdByAgentId) return actorId("agent", issue.createdByAgentId);
|
|
if (issue.createdByUserId) return actorId("user", issue.createdByUserId);
|
|
return actorId("system", "system");
|
|
}
|
|
|
|
function actorForIssueAssignee(issue: IssueRow) {
|
|
if (issue.assigneeAgentId) return actorId("agent", issue.assigneeAgentId);
|
|
if (issue.assigneeUserId) return actorId("user", issue.assigneeUserId);
|
|
return null;
|
|
}
|
|
|
|
async function getTimeline(input: WorkTimelineQuery): Promise<WorkTimelineResult> {
|
|
const { from, to, capped } = normalizeTimelineWindow(input);
|
|
const limit = normalizeLimit(input.limit);
|
|
const offset = normalizeOffset(input.offset);
|
|
|
|
const candidateIssueIds = await collectIssueIds(input, from, to);
|
|
const loadedIssues = await loadIssues(input, candidateIssueIds);
|
|
const userScopedIssues = await applyUserLens(input, loadedIssues, from, to);
|
|
const accessibleIssues = await filterReadableIssues(userScopedIssues, input.canReadIssue);
|
|
const sortedIssues = accessibleIssues.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
|
|
const pagedIssues = sortedIssues.slice(offset, offset + limit);
|
|
const issueById = new Map(pagedIssues.map((issue) => [issue.id, issue]));
|
|
const readableIssueIds = Array.from(issueById.keys());
|
|
|
|
if (readableIssueIds.length === 0) {
|
|
return {
|
|
actors: [],
|
|
spans: [],
|
|
events: [],
|
|
edges: [],
|
|
pagination: { limit, offset, totalIssues: sortedIssues.length, hasMore: offset + limit < sortedIssues.length },
|
|
window: { from: from.toISOString(), to: to.toISOString(), capped },
|
|
};
|
|
}
|
|
|
|
const actorIds = new Set<string>();
|
|
const events: WorkTimelineEvent[] = [];
|
|
const edges: WorkTimelineEdge[] = [];
|
|
|
|
for (const issue of pagedIssues) {
|
|
const creatorActorId = actorForIssueCreator(issue);
|
|
actorIds.add(creatorActorId);
|
|
events.push({
|
|
actorId: creatorActorId,
|
|
kind: "created",
|
|
issueId: issue.id,
|
|
at: issue.createdAt.toISOString(),
|
|
});
|
|
|
|
const assigneeActorId = actorForIssueAssignee(issue);
|
|
if (assigneeActorId) {
|
|
actorIds.add(assigneeActorId);
|
|
edges.push({
|
|
fromActorId: creatorActorId,
|
|
toActorId: assigneeActorId,
|
|
issueId: issue.id,
|
|
at: issue.createdAt.toISOString(),
|
|
kind: "assignment",
|
|
});
|
|
}
|
|
|
|
const parent = issue.parentId ? issueById.get(issue.parentId) : null;
|
|
const parentActorId = parent ? actorForIssueAssignee(parent) ?? actorForIssueCreator(parent) : null;
|
|
if (parentActorId && assigneeActorId && parentActorId !== assigneeActorId) {
|
|
actorIds.add(parentActorId);
|
|
edges.push({
|
|
fromActorId: parentActorId,
|
|
toActorId: assigneeActorId,
|
|
issueId: issue.id,
|
|
at: issue.createdAt.toISOString(),
|
|
kind: "delegation",
|
|
});
|
|
events.push({
|
|
actorId: parentActorId,
|
|
kind: "delegated",
|
|
issueId: issue.id,
|
|
at: issue.createdAt.toISOString(),
|
|
});
|
|
}
|
|
}
|
|
|
|
const [contextRunRows, activityRunRows, commentRows, approvalRows, interactionRows, logRows] = await Promise.all([
|
|
db
|
|
.select({
|
|
runId: heartbeatRuns.id,
|
|
agentId: heartbeatRuns.agentId,
|
|
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
|
status: heartbeatRuns.status,
|
|
startedAt: heartbeatRuns.startedAt,
|
|
finishedAt: heartbeatRuns.finishedAt,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
retryOfRunId: heartbeatRuns.retryOfRunId,
|
|
continuationAttempt: heartbeatRuns.continuationAttempt,
|
|
invocationSource: heartbeatRuns.invocationSource,
|
|
usageJson: heartbeatRuns.usageJson,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
runOverlapsWindow(from, to),
|
|
inArray(sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, readableIssueIds),
|
|
),
|
|
),
|
|
db
|
|
.select({
|
|
runId: heartbeatRuns.id,
|
|
agentId: heartbeatRuns.agentId,
|
|
issueId: activityLog.entityId,
|
|
status: heartbeatRuns.status,
|
|
startedAt: heartbeatRuns.startedAt,
|
|
finishedAt: heartbeatRuns.finishedAt,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
retryOfRunId: heartbeatRuns.retryOfRunId,
|
|
continuationAttempt: heartbeatRuns.continuationAttempt,
|
|
invocationSource: heartbeatRuns.invocationSource,
|
|
usageJson: heartbeatRuns.usageJson,
|
|
})
|
|
.from(activityLog)
|
|
.innerJoin(heartbeatRuns, eq(activityLog.runId, heartbeatRuns.id))
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, input.companyId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(activityLog.entityType, "issue"),
|
|
inArray(activityLog.entityId, readableIssueIds),
|
|
runOverlapsWindow(from, to),
|
|
),
|
|
),
|
|
db
|
|
.select({
|
|
issueId: issueComments.issueId,
|
|
authorAgentId: issueComments.authorAgentId,
|
|
authorUserId: issueComments.authorUserId,
|
|
createdAt: issueComments.createdAt,
|
|
})
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, input.companyId),
|
|
isNull(issueComments.deletedAt),
|
|
inArray(issueComments.issueId, readableIssueIds),
|
|
gte(issueComments.createdAt, from),
|
|
lte(issueComments.createdAt, to),
|
|
),
|
|
),
|
|
db
|
|
.select({
|
|
issueId: issueApprovals.issueId,
|
|
decidedByUserId: approvals.decidedByUserId,
|
|
decidedAt: approvals.decidedAt,
|
|
requestedByAgentId: approvals.requestedByAgentId,
|
|
requestedByUserId: approvals.requestedByUserId,
|
|
createdAt: approvals.createdAt,
|
|
})
|
|
.from(issueApprovals)
|
|
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
|
.where(
|
|
and(
|
|
eq(issueApprovals.companyId, input.companyId),
|
|
inArray(issueApprovals.issueId, readableIssueIds),
|
|
or(
|
|
and(gte(approvals.createdAt, from), lte(approvals.createdAt, to)),
|
|
and(gte(approvals.decidedAt, from), lte(approvals.decidedAt, to)),
|
|
),
|
|
),
|
|
),
|
|
db
|
|
.select({
|
|
issueId: issueThreadInteractions.issueId,
|
|
resolvedByAgentId: issueThreadInteractions.resolvedByAgentId,
|
|
resolvedByUserId: issueThreadInteractions.resolvedByUserId,
|
|
resolvedAt: issueThreadInteractions.resolvedAt,
|
|
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
|
createdByUserId: issueThreadInteractions.createdByUserId,
|
|
createdAt: issueThreadInteractions.createdAt,
|
|
})
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, input.companyId),
|
|
inArray(issueThreadInteractions.issueId, readableIssueIds),
|
|
or(
|
|
and(gte(issueThreadInteractions.createdAt, from), lte(issueThreadInteractions.createdAt, to)),
|
|
and(gte(issueThreadInteractions.resolvedAt, from), lte(issueThreadInteractions.resolvedAt, to)),
|
|
),
|
|
),
|
|
),
|
|
db
|
|
.select({
|
|
issueId: activityLog.entityId,
|
|
actorType: activityLog.actorType,
|
|
actorId: activityLog.actorId,
|
|
action: activityLog.action,
|
|
details: activityLog.details,
|
|
createdAt: activityLog.createdAt,
|
|
})
|
|
.from(activityLog)
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, input.companyId),
|
|
eq(activityLog.entityType, "issue"),
|
|
inArray(activityLog.entityId, readableIssueIds),
|
|
gte(activityLog.createdAt, from),
|
|
lte(activityLog.createdAt, to),
|
|
),
|
|
),
|
|
]);
|
|
|
|
const spanByRunId = new Map<string, WorkTimelineSpan>();
|
|
for (const row of [...contextRunRows, ...activityRunRows]) {
|
|
if (!row.issueId || !issueById.has(row.issueId) || spanByRunId.has(row.runId)) continue;
|
|
const runActorId = actorId("agent", row.agentId);
|
|
actorIds.add(runActorId);
|
|
spanByRunId.set(row.runId, {
|
|
actorId: runActorId,
|
|
laneHint: row.invocationSource ?? null,
|
|
runId: row.runId,
|
|
issueId: row.issueId,
|
|
issueIdentifier: issueById.get(row.issueId)?.identifier ?? null,
|
|
issueTitle: issueById.get(row.issueId)?.title ?? null,
|
|
start: (row.startedAt ?? row.createdAt).toISOString(),
|
|
end: dateIso(row.finishedAt),
|
|
status: row.status,
|
|
retryOfRunId: row.retryOfRunId ?? null,
|
|
continuationAttempt: row.continuationAttempt,
|
|
invocationSource: row.invocationSource ?? null,
|
|
usage: normalizeRunUsage(row.usageJson),
|
|
});
|
|
}
|
|
|
|
for (const row of commentRows) {
|
|
const commentActorId = row.authorAgentId
|
|
? actorId("agent", row.authorAgentId)
|
|
: row.authorUserId
|
|
? actorId("user", row.authorUserId)
|
|
: actorId("system", "system");
|
|
actorIds.add(commentActorId);
|
|
events.push({ actorId: commentActorId, kind: "commented", issueId: row.issueId, at: row.createdAt.toISOString() });
|
|
}
|
|
|
|
for (const row of approvalRows) {
|
|
const approvalActorId = row.decidedByUserId
|
|
? actorId("user", row.decidedByUserId)
|
|
: row.requestedByAgentId
|
|
? actorId("agent", row.requestedByAgentId)
|
|
: row.requestedByUserId
|
|
? actorId("user", row.requestedByUserId)
|
|
: actorId("system", "system");
|
|
actorIds.add(approvalActorId);
|
|
events.push({
|
|
actorId: approvalActorId,
|
|
kind: "approved",
|
|
issueId: row.issueId,
|
|
at: (row.decidedAt ?? row.createdAt).toISOString(),
|
|
});
|
|
}
|
|
|
|
for (const row of interactionRows) {
|
|
const interactionActorId = row.resolvedByUserId
|
|
? actorId("user", row.resolvedByUserId)
|
|
: row.resolvedByAgentId
|
|
? actorId("agent", row.resolvedByAgentId)
|
|
: row.createdByAgentId
|
|
? actorId("agent", row.createdByAgentId)
|
|
: row.createdByUserId
|
|
? actorId("user", row.createdByUserId)
|
|
: actorId("system", "system");
|
|
actorIds.add(interactionActorId);
|
|
events.push({
|
|
actorId: interactionActorId,
|
|
kind: "approved",
|
|
issueId: row.issueId,
|
|
at: (row.resolvedAt ?? row.createdAt).toISOString(),
|
|
});
|
|
}
|
|
|
|
for (const row of logRows) {
|
|
const logActorType = row.actorType === "agent" || row.actorType === "user" || row.actorType === "plugin"
|
|
? row.actorType
|
|
: "system";
|
|
const fromActorId = actorId(logActorType, row.actorId);
|
|
actorIds.add(fromActorId);
|
|
if (row.action.includes("assign")) {
|
|
events.push({ actorId: fromActorId, kind: "assigned", issueId: row.issueId, at: row.createdAt.toISOString() });
|
|
const details = row.details && typeof row.details === "object" && !Array.isArray(row.details)
|
|
? row.details as Record<string, unknown>
|
|
: {};
|
|
const targetAgentId = readString(details.assigneeAgentId) ?? readString(details.toAgentId);
|
|
const targetUserId = readString(details.assigneeUserId) ?? readString(details.toUserId);
|
|
const toActorId = targetAgentId
|
|
? actorId("agent", targetAgentId)
|
|
: targetUserId
|
|
? actorId("user", targetUserId)
|
|
: null;
|
|
if (toActorId) {
|
|
actorIds.add(toActorId);
|
|
edges.push({
|
|
fromActorId,
|
|
toActorId,
|
|
issueId: row.issueId,
|
|
at: row.createdAt.toISOString(),
|
|
kind: "assignment",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const actorMaps = await loadActorMaps(input.companyId, actorIds);
|
|
const actors: WorkTimelineActor[] = Array.from(actorIds).map((id) => {
|
|
const [type, rawId] = id.split(":", 2) as [TimelineActorType, string];
|
|
if (type === "agent") {
|
|
const agent = actorMaps.agents.get(rawId);
|
|
return { id, type, name: agent?.name ?? "Unknown agent", avatar: agent?.icon ?? null };
|
|
}
|
|
if (type === "user") {
|
|
const user = actorMaps.users.get(rawId);
|
|
return { id, type, name: user?.name ?? rawId, avatar: user?.image ?? null };
|
|
}
|
|
return { id, type, name: type === "plugin" ? rawId : "System", avatar: null };
|
|
});
|
|
|
|
return {
|
|
actors,
|
|
spans: Array.from(spanByRunId.values()).sort((left, right) => left.start.localeCompare(right.start)),
|
|
events: events.sort((left, right) => left.at.localeCompare(right.at)),
|
|
edges: edges.sort((left, right) => left.at.localeCompare(right.at)),
|
|
pagination: {
|
|
limit,
|
|
offset,
|
|
totalIssues: sortedIssues.length,
|
|
hasMore: offset + limit < sortedIssues.length,
|
|
},
|
|
window: { from: from.toISOString(), to: to.toISOString(), capped },
|
|
};
|
|
}
|
|
|
|
return { getTimeline };
|
|
}
|