diff --git a/cli/src/commands/client/issue.ts b/cli/src/commands/client/issue.ts index 2e4cdf6ddc0..e2c067a1593 100644 --- a/cli/src/commands/client/issue.ts +++ b/cli/src/commands/client/issue.ts @@ -59,6 +59,7 @@ interface IssueCreateOptions extends BaseClientOptions { projectId?: string; goalId?: string; parentId?: string; + standalone?: boolean; requestDepth?: string; billingCode?: string; } @@ -286,10 +287,17 @@ export function registerIssueCommands(program: Command): void { .option("--project-id ", "Project ID") .option("--goal-id ", "Goal ID") .option("--parent-id ", "Parent issue ID") + .option( + "--standalone", + "Create a top-level issue. Without --parent-id, an agent run's issue otherwise becomes the parent.", + ) .option("--request-depth ", "Request depth integer") .option("--billing-code ", "Billing code") .action(async (opts: IssueCreateOptions) => { try { + if (opts.standalone && opts.parentId) { + throw new Error("--standalone cannot be combined with --parent-id"); + } const ctx = resolveCommandContext(opts, { requireCompany: true }); const payload = createIssueSchema.parse({ title: opts.title, @@ -299,7 +307,7 @@ export function registerIssueCommands(program: Command): void { assigneeAgentId: opts.assigneeAgentId, projectId: opts.projectId, goalId: opts.goalId, - parentId: opts.parentId, + parentId: opts.standalone ? null : opts.parentId, requestDepth: parseOptionalInt(opts.requestDepth), billingCode: opts.billingCode, }); diff --git a/doc/CLI.md b/doc/CLI.md index c1a6da5659b..7efb1eb5801 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -424,7 +424,7 @@ Notes: ```sh npx paperclipai issue list --company-id [--status todo,in_progress] [--assignee-agent-id ] [--match text] npx paperclipai issue get -npx paperclipai issue create --company-id --title "..." [--description "..."] [--status todo] [--priority high] +npx paperclipai issue create --company-id --title "..." [--description "..."] [--status todo] [--priority high] [--parent-id | --standalone] npx paperclipai issue update [--status in_progress] [--comment "..."] npx paperclipai issue delete --yes npx paperclipai issue comment --body "..." [--attachment-id ] [--reopen] @@ -440,6 +440,8 @@ npx paperclipai issue release npx paperclipai issue force-release ``` +`issue create` sent from an agent run bound to an ordinary execution task is parented under that task when `--parent-id` is omitted; pass `--standalone` to create intentionally top-level work (`parentId: null`). The two flags are mutually exclusive. + Issue subresources are exposed as Paperclip API wrappers. Commands that map to broad server schemas accept JSON payloads and validate them with shared schemas before sending. ```sh diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index d5c9334627e..ce2344ddeb4 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -277,6 +277,8 @@ retain their titles and default to no generation request. - identifier fields: `issue_number`, `identifier` - origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint` - Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=` selects tasks created by runs bound to that source task, using native run issue identity or persisted legacy task context. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped. +- Delegated follow-ups keep their structural parent. When an agent run bound to an ordinary execution task creates an issue and omits `parentId`, the server defaults `parentId` to that run's task and records `parentDefaultedFromRunIssue: true` on the `issue.created` activity. An explicit `parentId: null` keeps the task standalone. The default never applies to conversation runs (chat handoffs stay top-level), to watchdog follow-ups, to board/user creates, to a run task the agent may not mutate, when the default parent would create a delegation cycle with the requested assignee, or when the agent may not create a child under the default parent (for example a protected assignment policy without a grant); those requests stay standalone instead of failing. An explicitly requested parent is still subject to the ordinary assignment check. Structural parentage, creation provenance and blockers stay distinct: the default adds no blocker. +- `GET /api/issues/:id` returns `createdFrom` (`{ issue: { id, identifier, title, status }, run: { id, agentId }, agent: { id, name } | null } | null`): the task the origin run was executing when it created this issue, resolved from `origin_run_id` or, for historical rows, the run on the recorded creation activity. It is present whether or not `parentId` is set, and only when the viewer may read the source task and its run; a private, hidden-from-viewer or cross-company source yields `null`, indistinguishable from missing provenance. - Relation lists can use `sortField=id&sortDir=asc&afterId=` for stable pagination. The cursor excludes earlier IDs and cannot be combined with an offset or activity-based order. - The streamlined task page's Tasks tab keeps two independent memberships: the existing subtask tree, and created tasks grouped by their current project (or No project). A created subtask appears in both. Only Subtasks has completion progress; groups collapse independently and unfinished tasks sort above finished tasks. - `request_depth` int not null default 0 diff --git a/doc/observability.md b/doc/observability.md index 56c550161bc..021c26ec87c 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -48,8 +48,9 @@ There are no custom attributes, identifiers, content, or exception messages. The closed phase names in `server/src/services/issue-read-timing.ts` are: `lookup`, `authorization`, `project_goal`, `ancestors`, `mentions`, `documents`, `relations`, `blockers`, `review`, `references`, `handoff`, `retry`, `recovery`, -`cases`, `inbox`, `channel`, `workspace`, `work_products`, `execution_blocker`, -`relation_recovery`, `revalidate_recovery`, and `mentioned_projects`. +`cases`, `inbox`, `channel`, `workspace`, `work_products`, `created_from`, +`execution_blocker`, `relation_recovery`, `revalidate_recovery`, and +`mentioned_projects`. Without an OTLP endpoint, spans remain no-ops. This adds no first-party Telemetry events or run-log events. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 110381b71a3..9dba828880f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1245,6 +1245,7 @@ export type { DocumentFormat, LegacyPlanDocument, IssueAttachment, + IssueCreationSource, IssueLabel, IssueTreeControlPreview, IssueTreeHold, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index ec590e8e140..be561795fda 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -804,6 +804,7 @@ export type { IssueAncestorProject, IssueAncestorGoal, IssueAttachment, + IssueCreationSource, IssueLabel, IssueWatchdog, IssueWatchdogStatus, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 31a9468a3f5..78d60aafbbd 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -128,6 +128,22 @@ export interface IssueLabel { updatedAt: Date; } +/** + * The task a run was executing when it created this issue, from recorded run + * provenance. Independent of the structural parent and of blockers. Only + * present when the viewer may read the source task and its run. + */ +export interface IssueCreationSource { + issue: { + id: string; + identifier: string | null; + title: string; + status: string; + }; + run: { id: string; agentId: string }; + agent: { id: string; name: string } | null; +} + export interface IssueAssigneeAdapterOverrides { adapterConfig?: Record; useProjectWorkspace?: boolean; @@ -839,6 +855,8 @@ export interface Issue { /** Immediate task from which private access flows downward, including chat handoffs. */ privacyParentIssueId?: string | null; ancestors?: IssueAncestor[]; + /** Detail responses only: access-checked creation provenance, or null. */ + createdFrom?: IssueCreationSource | null; title: string; description: string | null; descriptionTruncated?: boolean; diff --git a/server/src/__tests__/issue-creation-source-routes.test.ts b/server/src/__tests__/issue-creation-source-routes.test.ts new file mode 100644 index 00000000000..1d259b374e1 --- /dev/null +++ b/server/src/__tests__/issue-creation-source-routes.test.ts @@ -0,0 +1,217 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; + +describePostgres("agent-created follow-ups: parent links and creation source", () => { + let db: ReturnType; + let database: Awaited>; + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const qaAgentId = randomUUID(); + const engineerAgentId = randomUUID(); + const foreignAgentId = randomUUID(); + const sourceId = randomUUID(); + const conversationId = randomUUID(); + const privateSourceId = randomUUID(); + const protectedSourceId = randomUUID(); + const foreignSourceId = randomUUID(); + const runOnSource = randomUUID(); + const runOnConversation = randomUUID(); + const runOnPrivate = randomUUID(); + const runOnProtected = randomUUID(); + const foreignRun = randomUUID(); + const legacyRun = randomUUID(); + + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-creation-source-"); + db = createDb(database.connectionString); + await db.insert(companies).values([ + { id: companyId, name: "Paperclip", issuePrefix: "PAP", defaultResponsibleUserId: "board-user", requireBoardApprovalForNewAgents: false }, + { id: otherCompanyId, name: "Other", issuePrefix: "OTH", defaultResponsibleUserId: "board-user", requireBoardApprovalForNewAgents: false }, + ]); + await db.insert(agents).values([ + { id: qaAgentId, companyId, name: "Paperclip QA", role: "qa", adapterType: "codex_local", status: "active" }, + { id: engineerAgentId, companyId, name: "Engineer", role: "engineer", adapterType: "codex_local", status: "active" }, + { id: foreignAgentId, companyId: otherCompanyId, name: "Other QA", role: "qa", adapterType: "codex_local", status: "active" }, + ]); + await db.insert(issues).values([ + { id: sourceId, companyId, title: "Verify Tailscale fix", identifier: "PAP-168", issueNumber: 168, status: "in_progress", assigneeAgentId: qaAgentId, createdByAgentId: engineerAgentId }, + { id: conversationId, companyId, title: "Chat with QA", identifier: "PAP-170", issueNumber: 170, status: "in_review", assigneeAgentId: qaAgentId, conversationAgentId: qaAgentId, conversationUserId: "board-user", conversationState: "waiting" }, + { id: privateSourceId, companyId, title: "Private finding", identifier: "PAP-171", issueNumber: 171, status: "in_progress", visibility: "private", privacyRootIssueId: privateSourceId, assigneeAgentId: qaAgentId }, + { id: protectedSourceId, companyId, title: "Protected task", identifier: "PAP-172", issueNumber: 172, status: "in_progress", assigneeAgentId: qaAgentId, executionPolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }, + { id: foreignSourceId, companyId: otherCompanyId, title: "Foreign source", identifier: "OTH-1", issueNumber: 1 }, + ]); + await db.insert(heartbeatRuns).values([ + { id: runOnSource, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: sourceId, nativeIssueId: sourceId, status: "running", contextSnapshot: { issueId: sourceId } }, + { id: runOnConversation, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: conversationId, status: "running", contextSnapshot: { issueId: conversationId } }, + { id: runOnPrivate, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: privateSourceId, status: "running", contextSnapshot: { issueId: privateSourceId } }, + { id: runOnProtected, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: protectedSourceId, status: "running", contextSnapshot: { issueId: protectedSourceId } }, + { id: foreignRun, companyId: otherCompanyId, agentId: foreignAgentId, scopeKind: "issue", issueId: foreignSourceId, status: "running" }, + { id: legacyRun, companyId, agentId: qaAgentId, status: "succeeded", contextSnapshot: { taskKey: "PAP-168" } }, + ]); + }, 30_000); + afterAll(async () => { await database?.cleanup(); }); + + const boardActor = { type: "board", userId: "board-user", source: "local_implicit", isInstanceAdmin: true } as Express.Request["actor"]; + function agentActor(agentId: string, runId?: string): Express.Request["actor"] { + return { type: "agent", companyId, agentId, runId, source: "agent_jwt", onBehalfOfUserId: null, onBehalfOfMemberships: [] } as Express.Request["actor"]; + } + function app(actor: Express.Request["actor"]) { + const instance = express(); + instance.use(express.json()); + instance.use((req, _res, next) => { req.actor = actor; next(); }); + instance.use("/api", issueRoutes(db, {} as never)); + instance.use(errorHandler); + return instance; + } + async function createdActivity(issueId: string) { + const [row] = await db.select().from(activityLog) + .where(and(eq(activityLog.entityId, issueId), eq(activityLog.action, "issue.created"))); + return row ?? null; + } + + it("parents an ordinary delegated follow-up under the run's task when parentId is omitted", async () => { + const created = await request(app(agentActor(qaAgentId, runOnSource))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Fix regression found during verification" }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(created.body.parentId).toBe(sourceId); + expect(created.body.originRunId).toBe(runOnSource); + expect(created.body.blockedBy ?? []).toEqual([]); + const activity = await createdActivity(created.body.id); + expect(activity?.details).toMatchObject({ parentId: sourceId, parentDefaultedFromRunIssue: true }); + + const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`); + expect(detail.status).toBe(200); + expect(detail.body.createdFrom).toEqual({ + issue: { id: sourceId, identifier: "PAP-168", title: "Verify Tailscale fix", status: "in_progress" }, + run: { id: runOnSource, agentId: qaAgentId }, + agent: { id: qaAgentId, name: "Paperclip QA" }, + }); + }); + + it("keeps an explicit parentId: null standalone but still exposes the source task", async () => { + const created = await request(app(agentActor(qaAgentId, runOnSource))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Intentional top-level follow-up", parentId: null }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(created.body.parentId).toBeNull(); + const activity = await createdActivity(created.body.id); + expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue"); + + const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`); + expect(detail.status).toBe(200); + expect(detail.body.parentId).toBeNull(); + expect(detail.body.createdFrom).toMatchObject({ + issue: { id: sourceId, identifier: "PAP-168" }, + agent: { name: "Paperclip QA" }, + }); + }); + + it("never parents conversation handoffs or board-created tasks", async () => { + const handoff = await request(app(agentActor(qaAgentId, runOnConversation))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Execution task from chat" }); + expect(handoff.status, JSON.stringify(handoff.body)).toBe(201); + expect(handoff.body.parentId).toBeNull(); + + const manual = await request(app(boardActor)) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Board task" }); + expect(manual.status, JSON.stringify(manual.body)).toBe(201); + expect(manual.body.parentId).toBeNull(); + const detail = await request(app(boardActor)).get(`/api/issues/${manual.body.id}`); + expect(detail.body.createdFrom).toBeNull(); + }); + + it("stays standalone instead of failing when the default parent would be a delegation cycle", async () => { + const created = await request(app(agentActor(qaAgentId, runOnSource))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Bug for the engineer who delegated to QA", assigneeAgentId: engineerAgentId }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(created.body.parentId).toBeNull(); + expect(created.body.assigneeAgentId).toBe(engineerAgentId); + const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`); + expect(detail.body.createdFrom).toMatchObject({ issue: { id: sourceId } }); + }); + + it("stays standalone instead of failing when the default parent is protected without a grant", async () => { + // The agent runs the protected task, so it may mutate it, but a protected + // assignment policy denies child creation without an explicit grant. + const created = await request(app(agentActor(qaAgentId, runOnProtected))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Follow-up from a protected task" }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(created.body.parentId).toBeNull(); + const activity = await createdActivity(created.body.id); + expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue"); + const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`); + expect(detail.body.createdFrom).toMatchObject({ issue: { id: protectedSourceId, identifier: "PAP-172" } }); + + const explicit = await request(app(agentActor(qaAgentId, runOnProtected))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Explicit child of a protected task", parentId: protectedSourceId }); + expect(explicit.status, JSON.stringify(explicit.body)).toBe(403); + }); + + it("still honors an explicit parent and rejects an explicit delegation cycle", async () => { + const [other] = await db.insert(issues).values({ companyId, title: "Another parent", status: "todo" }).returning(); + const created = await request(app(agentActor(qaAgentId, runOnSource))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Explicit child", parentId: other!.id }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(created.body.parentId).toBe(other!.id); + const activity = await createdActivity(created.body.id); + expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue"); + + const cycle = await request(app(agentActor(qaAgentId, runOnSource))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Explicit cycle", parentId: sourceId, assigneeAgentId: engineerAgentId }); + expect(cycle.status).toBe(409); + }); + + it("resolves historical provenance from the creation activity's run", async () => { + const historicalId = randomUUID(); + await db.insert(issues).values({ id: historicalId, companyId, title: "Historical follow-up", status: "todo", createdByAgentId: qaAgentId }); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: qaAgentId, runId: legacyRun, action: "issue.created", entityType: "issue", entityId: historicalId }); + const detail = await request(app(boardActor)).get(`/api/issues/${historicalId}`); + expect(detail.status).toBe(200); + expect(detail.body.createdFrom).toMatchObject({ issue: { id: sourceId, identifier: "PAP-168" }, run: { id: legacyRun } }); + }); + + it("returns null when provenance is missing, inaccessible, or from another company", async () => { + const orphanId = randomUUID(); + const fromPrivateId = randomUUID(); + const fromForeignId = randomUUID(); + await db.insert(issues).values([ + { id: orphanId, companyId, title: "No provenance", status: "todo", createdByAgentId: qaAgentId }, + { id: fromPrivateId, companyId, title: "Created from a private task", status: "todo", originRunId: runOnPrivate, createdByAgentId: qaAgentId }, + { id: fromForeignId, companyId, title: "Origin run in another company", status: "todo", originRunId: foreignRun }, + ]); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: qaAgentId, runId: null, action: "issue.created", entityType: "issue", entityId: orphanId }); + + const orphan = await request(app(boardActor)).get(`/api/issues/${orphanId}`); + expect(orphan.body.createdFrom).toBeNull(); + + const foreign = await request(app(boardActor)).get(`/api/issues/${fromForeignId}`); + expect(foreign.status).toBe(200); + expect(foreign.body.createdFrom).toBeNull(); + + const asOutsider = await request(app(agentActor(engineerAgentId))).get(`/api/issues/${fromPrivateId}`); + expect(asOutsider.status, JSON.stringify(asOutsider.body)).toBe(200); + expect(asOutsider.body.createdFrom).toBeNull(); + + const asParticipant = await request(app(agentActor(qaAgentId))).get(`/api/issues/${fromPrivateId}`); + expect(asParticipant.status).toBe(200); + expect(asParticipant.body.createdFrom).toMatchObject({ issue: { id: privateSourceId, identifier: "PAP-171" } }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f738e91abac..149fef0a791 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3,6 +3,11 @@ import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from " import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js"; import type { IssuePrivacyConstraints } from "@paperclipai/shared"; import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; +import { + loadCreationSourceAgent, + loadRunBoundIssue, + resolveIssueCreationSource, +} from "../services/issue-creation-source.js"; import { activeIssueInteractionCondition, readTaskQuestionContext } from "../services/issue-question-context.js"; import { setIssueTitle } from "../services/issue-title.js"; import { setIssueTitleSchema } from "@paperclipai/shared"; @@ -113,6 +118,7 @@ import { isMarkdownArtifactWorkProduct, isMarkdownAttachmentContent, isUuidLike, + type IssueCreationSource, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, type CompactIssue, type CompanySearchExtractQuery, @@ -3755,6 +3761,124 @@ export function issueRoutes( ); } + /** + * Default structural parent for a delegated follow-up: the task the agent's + * run is executing. Returns null when the new task should stay standalone: + * no run-bound task, a conversation (chat handoffs are top-level by design), + * a task the agent may not mutate, a parent that would create a delegation + * cycle with the requested assignee, or a parent the agent may not create + * children under (for example a protected assignment policy without a + * grant). The default must never turn a previously allowed standalone + * create into a denial; an explicitly requested parent is still rejected by + * the ordinary assignment check. Callers only use this when the request + * omitted `parentId`; an explicit `parentId: null` keeps the task standalone. + */ + async function resolveRunDelegationParentDefault( + req: Request, + companyId: string, + rawCreateBody: Omit< + Parameters[0], + "companyId" | "parentId" + > & { assigneeUserId?: string | null }, + assigneeAgentId: string | null, + ): Promise { + if ( + req.actor.type !== "agent" || + !req.actor.agentId || + !req.actor.runId || + !isUuidLike(req.actor.runId) + ) + return null; + const run = await db + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, req.actor.runId), + eq(heartbeatRuns.companyId, companyId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!run || run.agentId !== req.actor.agentId) return null; + const source = await loadRunBoundIssue(db, run); + if (!source || isConversation(source)) return null; + const decision = await decideIssueAccess(req, source, "issue:mutate"); + if (!decision.allowed) return null; + if (assigneeAgentId && assigneeAgentId !== req.actor.agentId) { + const ancestor = await svc.findOpenAncestorCreatedByAgent( + source.id, + assigneeAgentId, + ); + if (ancestor) return null; + } + // Mirror the assignment check the create path runs with the defaulted + // parent in scope. A protected parent (or project it supplies) denies + // child creation without a grant; that must fall back to standalone work + // rather than fail a request that succeeded before the default existed. + const assignmentScope: TaskAssignmentAuthorizationScope = { + projectId: await resolveCreateAssignmentProjectId({ + ...rawCreateBody, + companyId, + parentId: source.id, + }), + parentIssueId: source.id, + assigneeAgentId, + assigneeUserId: + typeof rawCreateBody.assigneeUserId === "string" + ? rawCreateBody.assigneeUserId + : null, + }; + const assignment = await access.decide({ + actor: req.actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId, + issueId: null, + projectId: assignmentScope.projectId ?? null, + parentIssueId: source.id, + assigneeAgentId, + assigneeUserId: assignmentScope.assigneeUserId ?? null, + }, + scope: assignmentScope, + }); + if (!assignment.allowed) return null; + return source.id; + } + + /** + * Creation provenance the viewer may see: the source task must be readable + * and the run must be visible to the actor. Returns null otherwise, so a + * private or foreign source is indistinguishable from no provenance. + */ + async function resolveVisibleIssueCreationSource( + req: Request, + issue: { id: string; companyId: string; originRunId: string | null }, + ): Promise { + const source = await resolveIssueCreationSource(db, issue); + if (!source) return null; + const { run, sourceIssue } = source; + const readable = await decideIssueAccess(req, sourceIssue, "issue:read"); + if (!readable.allowed) return null; + const runVisible = await canActorReadHeartbeatRun(db, access, req.actor, { + companyId: run.companyId, + scopeKind: run.scopeKind, + issueId: run.issueId ?? run.nativeIssueId ?? sourceIssue.id, + }); + if (!runVisible) return null; + const agent = await loadCreationSourceAgent(db, issue.companyId, run.agentId); + return { + issue: { + id: sourceIssue.id, + identifier: sourceIssue.identifier, + title: sourceIssue.title, + status: sourceIssue.status, + }, + run: { id: run.id, agentId: run.agentId }, + agent, + }; + } + async function resolveAgentTrustForIssue( input: { agentId: string | null | undefined; @@ -9146,6 +9270,7 @@ export function issueRoutes( externalChannelBinding, currentExecutionWorkspace, workProducts, + createdFrom, ] = await Promise.all([ timing.time("project_goal", () => resolveIssueProjectAndGoal(issue)), timing.time("ancestors", () => svc.getAncestors(issue.id)), @@ -9169,6 +9294,7 @@ export function issueRoutes( ? executionWorkspacesSvc.getById(issue.executionWorkspaceId) : Promise.resolve(null)), timing.time("work_products", () => workProductsSvc.listForIssue(issue.id)), + timing.time("created_from", () => resolveVisibleIssueCreationSource(req, issue)), ]); const [recoveryActionsByRelationIssue, revalidatedActiveRecoveryAction, mentionedProjects] = await Promise.all([ timing.time("relation_recovery", () => relationRecoveryActionMap(recoveryActionsSvc, issue.companyId, relations)), @@ -9218,6 +9344,7 @@ export function issueRoutes( workProducts, linkedCases, externalChannelBinding, + createdFrom, }); }); @@ -12061,9 +12188,27 @@ export function issueRoutes( watchdogDiscovery, ); if (watchdogProductBugFollowUp === false) return; + const normalizedAssigneeAgentId = + await normalizeIssueAssigneeAgentReference( + companyId, + rawCreateBody.assigneeAgentId as string | null | undefined, + { actorType: req.actor.type }, + ); + // A delegated follow-up from an ordinary execution task keeps that task as + // its structural parent when the agent omits `parentId`. An explicit + // `parentId: null` is an intentional standalone task and is honored. + const runDelegationParentId = + !watchdogProductBugFollowUp && rawCreateBody.parentId === undefined + ? await resolveRunDelegationParentDefault( + req, + companyId, + rawCreateBody, + normalizedAssigneeAgentId ?? null, + ) + : null; const effectiveParentId = watchdogProductBugFollowUp ? null - : rawCreateBody.parentId; + : (runDelegationParentId ?? rawCreateBody.parentId); let createParent: Awaited> | null = null; if (req.actor.type === "agent" && effectiveParentId) { createParent = await svc.getById(effectiveParentId); @@ -12087,12 +12232,6 @@ export function issueRoutes( )) ) return; - const normalizedAssigneeAgentId = - await normalizeIssueAssigneeAgentReference( - companyId, - rawCreateBody.assigneeAgentId as string | null | undefined, - { actorType: req.actor.type }, - ); await assertNoAgentDelegationCycle({ actorType: req.actor.type, actorAgentId: req.actor.agentId, @@ -12328,6 +12467,9 @@ export function issueRoutes( details: { title: issue.title, identifier: issue.identifier, + ...(runDelegationParentId && issue.parentId === runDelegationParentId + ? { parentId: issue.parentId, parentDefaultedFromRunIssue: true } + : {}), ...(watchdogProductBugFollowUp ? { watchdogDiscovery: { diff --git a/server/src/services/issue-creation-source.ts b/server/src/services/issue-creation-source.ts new file mode 100644 index 00000000000..42f5ce816a7 --- /dev/null +++ b/server/src/services/issue-creation-source.ts @@ -0,0 +1,111 @@ +import { and, asc, eq, inArray, isNotNull } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db"; +import { isUuidLike } from "@paperclipai/shared"; + +type IssueRow = typeof issues.$inferSelect; +type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect; + +/** The task a run was executing when it created another task. */ +export interface IssueCreationSourceRecord { + run: HeartbeatRunRow; + sourceIssue: IssueRow; +} + +const CREATION_ACTIVITY_ACTIONS = ["issue.created", "issue.child_created"]; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +/** + * The issue a heartbeat run is bound to: native run identity first, then the + * run's issue scope, then the persisted legacy task context. Returns a UUID or + * an issue identifier such as `PAP-168`; the caller resolves identifiers. + */ +export function runBoundIssueReference(run: Pick): string | null { + const context = run.contextSnapshot && typeof run.contextSnapshot === "object" + ? (run.contextSnapshot as Record) + : null; + const paperclipIssue = context?.paperclipIssue && typeof context.paperclipIssue === "object" + ? (context.paperclipIssue as Record) + : null; + return ( + run.nativeIssueId ?? + run.issueId ?? + readNonEmptyString(context?.issueId) ?? + readNonEmptyString(context?.taskId) ?? + readNonEmptyString(paperclipIssue?.id) ?? + readNonEmptyString(context?.taskKey) + ); +} + +/** Loads the run's bound issue within the run's company, or null. */ +export async function loadRunBoundIssue( + db: Db, + run: Pick, +): Promise { + const reference = runBoundIssueReference(run); + if (!reference) return null; + const condition = isUuidLike(reference) + ? eq(issues.id, reference) + : eq(issues.identifier, reference); + const [issue] = await db + .select() + .from(issues) + .where(and(condition, eq(issues.companyId, run.companyId))) + .limit(1); + return issue ?? null; +} + +/** + * Resolves the task from which an issue was created, using recorded run + * provenance: the issue's origin run, or for historical rows without one, the + * run on its creation activity. Company-scoped; a run or source task from + * another company never resolves. Does not check viewer access. + */ +export async function resolveIssueCreationSource( + db: Db, + issue: Pick, +): Promise { + let runId = issue.originRunId; + if (!runId) { + const [creation] = await db + .select({ runId: activityLog.runId }) + .from(activityLog) + .where(and( + eq(activityLog.companyId, issue.companyId), + eq(activityLog.entityType, "issue"), + eq(activityLog.entityId, issue.id), + inArray(activityLog.action, CREATION_ACTIVITY_ACTIONS), + isNotNull(activityLog.runId), + )) + .orderBy(asc(activityLog.createdAt)) + .limit(1); + runId = creation?.runId ?? null; + } + if (!runId || !isUuidLike(runId)) return null; + const [run] = await db + .select() + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId))) + .limit(1); + if (!run) return null; + const sourceIssue = await loadRunBoundIssue(db, run); + if (!sourceIssue || sourceIssue.id === issue.id) return null; + return { run, sourceIssue }; +} + +/** Company-scoped agent summary for a creation source. */ +export async function loadCreationSourceAgent( + db: Db, + companyId: string, + agentId: string, +): Promise<{ id: string; name: string } | null> { + const [agent] = await db + .select({ id: agents.id, name: agents.name }) + .from(agents) + .where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))) + .limit(1); + return agent ?? null; +} diff --git a/server/src/services/issue-read-timing.ts b/server/src/services/issue-read-timing.ts index 332bf3d97fc..a3244e6f653 100644 --- a/server/src/services/issue-read-timing.ts +++ b/server/src/services/issue-read-timing.ts @@ -5,7 +5,7 @@ import { SpanStatusCode, trace } from "@opentelemetry/api"; type IssueReadPhase = "lookup" | "authorization" | "project_goal" | "ancestors" | "mentions" | "documents" | "relations" | "blockers" | "review" | "references" | "handoff" | "retry" | "recovery" | "cases" | "inbox" - | "channel" | "workspace" | "work_products" | "execution_blocker" + | "channel" | "workspace" | "work_products" | "created_from" | "execution_blocker" | "relation_recovery" | "revalidate_recovery" | "mentioned_projects"; export function createIssueReadTiming() { diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index f2ec350c7da..c3b6993bfd0 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -219,7 +219,7 @@ Before ending any heartbeat, apply this final-disposition checklist: - `done`: the requested work is complete, verification is recorded, and no follow-up remains on this issue. - `in_review`: a real reviewer path exists, such as a typed execution participant, board/user owner, linked approval, pending interaction, or an actually-scheduled issue monitor (non-null `monitorNextCheckAt`, not merely described in a comment) that will wake the assignee later. Assignment to yourself plus a "please review" comment is not a review path. - `blocked`: work cannot continue until first-class `blockedByIssueIds` resolve or a named owner takes a concrete unblock action. -- Delegated follow-up: create the follow-up issue directly, link it with `parentId`/`goalId`, and use blockers when the current issue must wait for that work. +- Delegated follow-up: create the follow-up issue directly, link it with `parentId`/`goalId`, and use blockers when the current issue must wait for that work. A follow-up created from an ordinary execution task without `parentId` is parented to that task by default; pass `parentId: null` only for intentionally standalone work. - Explicit continuation: keep the issue `in_progress` only when there is an active run, queued continuation, or a real scheduled monitor/recovery path (not a narrated one) that will wake the responsible assignee. Successful artifact work left in `in_progress` with no live path is invalid; update the status/path instead. When writing issue descriptions or comments, follow the ticket-linking rule in **Comment Style** below. @@ -264,7 +264,7 @@ Because of that, follow these rules: - **Never imply a live watcher on a task you are marking `done`.** `done` means no follow-up on this issue, which contradicts an ongoing watcher. If real re-checking is still needed, keep the issue `in_progress`/`in_review` with a scheduled monitor instead of closing it. - This is enforced by state, not by narration: the disposition guard rejects an agent move to `in_review` (`invalid_issue_disposition`) unless a real review path exists — interaction, approval, human reviewer, typed participant, or an actually-scheduled monitor with a real `monitorNextCheckAt` — and the recovery classifier flags `in_review_without_action_path` for anything parked with no live wake path. Keep your comments consistent with that real state. -**Step 9 — Delegate if needed.** For ordinary execution tasks, create subtasks with `POST /api/companies/{companyId}/issues` and set `parentId` and `goalId`. For conversation tasks, use the project handoff above instead. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. +**Step 9 — Delegate if needed.** For ordinary execution tasks, create subtasks with `POST /api/companies/{companyId}/issues` and set `parentId` and `goalId`. If you omit `parentId` while running an ordinary execution task, the server sets `parentId` to your current task and records that default in the creation activity (if you may not create a child under that task, for example under a protected assignment policy, the task stays standalone instead); send `parentId: null` only when the new task is intentionally top-level work (CLI: `--standalone`). Either way the follow-up keeps run provenance, and the task page shows "Created from by " without implying a blocker. For conversation tasks, use the project handoff above instead. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. ### Delegating review tasks diff --git a/ui/src/components/task-detail/IssueCreatedFromNote.tsx b/ui/src/components/task-detail/IssueCreatedFromNote.tsx new file mode 100644 index 00000000000..839cdb9b2eb --- /dev/null +++ b/ui/src/components/task-detail/IssueCreatedFromNote.tsx @@ -0,0 +1,45 @@ +import type { IssueCreationSource } from "@paperclipai/shared"; +import { GitBranch } from "lucide-react"; +import { + createIssueDetailPath, + rememberIssueDetailLocationState, +} from "@/lib/issueDetailBreadcrumb"; +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; + +export interface IssueCreatedFromNoteProps { + /** Access-checked creation provenance from the issue detail response. */ + createdFrom: IssueCreationSource | null | undefined; + issueLinkState?: unknown; + className?: string; +} + +/** + * "Created from PAP-168 by Paperclip QA": the task a run was executing when it + * created this one. Provenance only; it is not the structural parent and it is + * not a blocker. Renders nothing when the viewer may not see the source. + */ +export function IssueCreatedFromNote({ createdFrom, issueLinkState, className }: IssueCreatedFromNoteProps) { + if (!createdFrom) return null; + const { issue, agent } = createdFrom; + const ref = issue.identifier ?? issue.id; + return ( +

+ + Created from + rememberIssueDetailLocationState(ref, issueLinkState)} + className="min-w-0 truncate hover:text-foreground transition-colors" + title={issue.title} + > + {issue.identifier ? `${issue.identifier} ${issue.title}` : issue.title} + + {agent && by {agent.name}} +

+ ); +} diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx index 8da9642cf0c..866efd786db 100644 --- a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx @@ -98,4 +98,30 @@ describe("TaskDetailTasksPanel", () => { expect(retry).toHaveBeenCalledOnce(); expect(container.querySelector('[data-task-id="child"]')).not.toBeNull(); }); + + it("shows the access-checked creation source even when the task has no parent", () => { + render({ + createdFrom: { + issue: { id: "source", identifier: "PAP-168", title: "Verify Tailscale fix", status: "in_progress" }, + run: { id: "run-1", agentId: "qa" }, + agent: { id: "qa", name: "Paperclip QA" }, + }, + subtasks: [], createdTasks: [], projects: [], + }); + const group = container.querySelector('section[aria-label="Created from"]')!; + expect(group).not.toBeNull(); + const note = group.querySelector('[data-testid="issue-created-from"]')!; + expect(note.textContent).toContain("Created from"); + expect(note.textContent).toContain("PAP-168 Verify Tailscale fix"); + expect(note.textContent).toContain("by Paperclip QA"); + expect(note.querySelector('a')?.getAttribute("href")).toBe("/issues/PAP-168"); + expect(container.querySelector('section[aria-label="Ancestors"]')).toBeNull(); + expect(container.textContent).not.toContain("No tasks yet."); + }); + + it("omits the creation source when the viewer may not see it", () => { + render({ createdFrom: null, subtasks: [], createdTasks: [], projects: [] }); + expect(container.querySelector('section[aria-label="Created from"]')).toBeNull(); + expect(container.textContent).toContain("No tasks yet."); + }); }); diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.tsx index cb48ebe5193..8f3f91738f4 100644 --- a/ui/src/components/task-detail/TaskDetailTasksPanel.tsx +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.tsx @@ -7,6 +7,7 @@ import { issueStatusOrder } from "@/lib/issue-filters"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { RelationNavigationList, TaskDetailSubtasksPanel, TaskDetailTaskList } from "./TaskDetailRelationsPanel"; +import { IssueCreatedFromNote } from "./IssueCreatedFromNote"; function TaskGroup({ name, projectPath, children }: { name: string; projectPath?: string; children: ReactNode }) { return ( @@ -36,6 +37,8 @@ function TaskGroup({ name, projectPath, children }: { name: string; projectPath? export interface TaskDetailTasksPanelProps { /** API order: immediate parent first. Displayed root first. */ ancestors?: NonNullable; + /** Access-checked creation provenance; shown even when the source is not the parent. */ + createdFrom?: Issue["createdFrom"]; subtasks: Issue[]; createdTasks: Issue[]; projects: Project[]; @@ -45,7 +48,7 @@ export interface TaskDetailTasksPanelProps { issueLinkState?: unknown; } -export function TaskDetailTasksPanel({ ancestors = [], subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) { +export function TaskDetailTasksPanel({ ancestors = [], createdFrom = null, subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) { const sortedSubtasks = sortTasks(subtasks); const groups = new Map(); for (const item of sortTasks(createdTasks)) { @@ -63,6 +66,11 @@ export function TaskDetailTasksPanel({ ancestors = [], subtasks, createdTasks, p } return (
+ {createdFrom && ( + + + + )} {ancestors.length > 0 && ( Retry} )} - {!isLoading && !hasError && ancestors.length === 0 && subtasks.length === 0 && createdTasks.length === 0 && ( + {!isLoading && !hasError && !createdFrom && ancestors.length === 0 && subtasks.length === 0 && createdTasks.length === 0 && (

No tasks yet.

)}
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 69d2043dc49..7249989b805 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -2471,7 +2471,12 @@ describe("IssueDetail", () => { it("loads ancestors, subtask membership and created work independently and refreshes on issue activity", async () => { const ancestors = [{ id: "parent-task", identifier: "PAP-0", title: "Parent task", status: "in_progress" }] as Issue["ancestors"]; - const source = createIssue({ ancestors }); + const createdFrom: Issue["createdFrom"] = { + issue: { id: "origin-task", identifier: "PAP-168", title: "Origin task", status: "in_progress" }, + run: { id: "run-1", agentId: "qa-agent" }, + agent: { id: "qa-agent", name: "Paperclip QA" }, + }; + const source = createIssue({ ancestors, createdFrom }); const child = createIssue({ id: "manual-child", parentId: source.id, title: "Manual child" }); const created = createIssue({ id: "created-task", parentId: null, title: "Created elsewhere" }); mockIssuesApi.get.mockResolvedValue(source); @@ -2485,7 +2490,8 @@ describe("IssueDetail", () => { expect(taskProjection()?.content.props.subtasks.map((row: Issue) => row.id)).toEqual([child.id]); expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toEqual([created.id]); expect(taskProjection()?.content.props.ancestors).toEqual(ancestors); - expect(taskProjection()?.count).toBe(3); + expect(taskProjection()?.content.props.createdFrom).toEqual(createdFrom); + expect(taskProjection()?.count).toBe(4); const next = createIssue({ id: "new-created-task", parentId: source.id }); mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) => @@ -2493,7 +2499,7 @@ describe("IssueDetail", () => { ); await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(source.companyId) }); }); await flushReact(); - expect(taskProjection()?.count).toBe(4); + expect(taskProjection()?.count).toBe(5); expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toContain(next.id); }); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 7b5dbcd5413..a7b7ff66094 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -16,6 +16,7 @@ import { agentDetailHref } from "./agent-detail-navigation"; import { ExecutionBlockerNotice } from "../components/ExecutionBlockerNotice"; import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover"; import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel"; +import { IssueCreatedFromNote } from "@/components/task-detail/IssueCreatedFromNote"; import { EmailThreadProvider } from "../components/EmailMessageCard"; import { EmailTaskActivity } from "../components/EmailTaskActivity"; import { TaskChatScrollNavigation, taskChatScrollEntry } from "@/components/task-chat/scroll-navigation"; @@ -3654,11 +3655,17 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS const createdTasks = createdTasksQuery.data ?? EMPTY_ISSUES; const hasError = createdTasksQuery.isError || childIssuesError; return { - count: new Set([...(issue?.ancestors ?? []), ...childIssues, ...createdTasks].map((task) => task.id)).size, + count: new Set([ + ...(issue?.createdFrom ? [issue.createdFrom.issue] : []), + ...(issue?.ancestors ?? []), + ...childIssues, + ...createdTasks, + ].map((task) => task.id)).size, hasError, content: ( ) : null; + // Creation provenance ("Created from PAP-168 by Paperclip QA"). Distinct from + // the parent chain above; the streamlined Tasks tab renders it instead. + const createdFromNote = + !streamlinedTaskDetailEnabled && issue.createdFrom ? ( + + ) : null; const issueStatusControl = ( {ancestorsNav} + {createdFromNote} {issueHeaderBlock} {pluginOutletsBlock} @@ -7620,6 +7639,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS > {/* Parent chain breadcrumb (redesign: rendered inside the thread viewport) */} {taskChatShellEnabled ? null : ancestorsNav} + {taskChatShellEnabled ? null : createdFromNote}