mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents split work into tasks. A task can have a parent task. > - Today agents can nest tasks to any depth: parent, child, grandchild, and deeper. > - Deep agent-made trees are hard to follow, and an agent that works on a child task makes grandchildren by default. > - This pull request limits agent-created trees to two levels: a top-level task and its children. > - A request for a grandchild does not fail. The server puts the new task beside the requester and tells the agent. > - Board users keep full control. They can still nest to any depth. Existing deep trees stay valid. > - The pull request also records which task created each new task, so the audit trail stays complete when the parent changes. ## Linked Issues or Issue Description No public issue exists. This is the feature description. Related: #16853 (budgets that include descendant work) also reasons about task trees; this change does not alter its traversal. **Subsystem affected** Issues (tasks): create, child create and move in the issue service and routes. Agent tools, chat suggestions and creation provenance use these paths. **Problem or motivation** Agents create subtasks as deep as they want. An agent that runs on a child task gets that child as the default parent, so its follow-ups become grandchildren. The trees become deep and hard to read. The parent link is also the only record of which task created a follow-up, so that record is lost when the parent changes. **Proposed solution** Limit agent-created trees to a parent and its children. When an agent asks for a grandchild, put the new task under the requester's parent and return `parentAdjusted`. Do not reject the request. Keep board users unrestricted. Record the originating task in a new server-set `origin_issue_id` column. Keep `origin_run_id` as the originating run. **Alternatives considered** Reject grandchild requests with an error. We did not do this, because agents then fail mid-task. Add a database constraint. We did not do this, because existing deep trees must stay valid and nesting can return later. ## What Changed - Add `MAX_AGENT_TASK_HIERARCHY_DEPTH` (2) and the `IssueParentAdjustment` type in `packages/shared`. - Add `resolveNewTaskParent` and `planIssueParentChange` in `server/src/services/issues.ts`. `create`, `createChild` and `update` use the agent limit by default (`parentDepthPolicy: "flatten"`). - The issue create, `/children` and PATCH routes pass `allow_nested` for board users. For agents, the routes check authorization against the parent that the server actually writes. - The requester keeps its behavior when the server moves a task beside it: `blockParentUntilDone` blocks the requester, and the new task inherits the requester's workspace and privacy. - The server never moves a task above a low-trust boundary root that contains the requester. - These subsystems keep any depth, because they find their work by parent: watchdog reviews, monitor recovery, email-channel placement, and routines that a person configured. - An agent move of a task that has children skips only the parent change and returns `reason: "has_children"`. - Add `issues.origin_issue_id` (nullable, `on delete set null`, indexed) in migration `0434`. Only the server sets it. The `createdFrom` detail field, the `createdFromIssueId` filter and task-creation provenance read it first. - Chat prompts now let a chat agent create one task, a task with children, or separate tasks. The conversation is never a parent. - The runner `create_task` tool accepts an optional `parentTaskId`. A chat agent can create a top-level task and then put subtasks under it. The run must be able to read and change the parent. A conversation is never a parent. The depth limit still applies. Project tools map the field to `parentId` through the issue route. The protocol manifest, semantic contracts and eval fixture are regenerated. - Watchdog runs that have an active watchdog keep their requested parent on all create paths, because their access is limited to the watched subtree. - Fix a bug that the test drive found: the chat (MCP) `create_task` ignored the requested `status`, so a `backlog` task became `todo` and started to run. It now uses the same rule as the native tool. - Update `skills/paperclip`, `doc/SPEC-implementation.md`, and add the plan `doc/plans/2026-11-05-two-level-task-hierarchy.md`. ## Verification - `cd server && npx vitest run src/__tests__/agent-task-hierarchy-depth.test.ts src/__tests__/agent-task-hierarchy-depth-routes.test.ts`: 19 new tests for service and route behavior, all pass. - Related suites (64 files: issues service, thread interactions, privacy, watchdogs, provenance, runner tool authority, decisions, routines, project tools): 1208 tests pass. Two tests changed because of the intended behavior and pass now. Two `email-channels.integration` tests fail locally because the test cannot find a bundled skill file from this worktree path. This change does not touch that code. - `packages/db`: migration safety, snapshot drift and journal tests pass. - Typecheck passes for `server`, `packages/shared`, `packages/db`, `cli` and `ui` (local `ui` errors come only from generated Storybook catalog files that are absent). - Local test drive on an isolated instance with real Claude agents: 29 of 29 use cases pass, including an agent on a child task (follow-ups placed beside it, with the notice) and a chat that creates a top-level task with three subtasks. - I did not run the full `pnpm test:run` or `pnpm build` locally. CI runs them. ## Risks - Behavior change for agents: some follow-ups that were grandchildren are now siblings. The parent of the requester is woken when all its children finish, which now includes these tasks. - The runner `create_task` input contract adds one optional field, so the protocol manifest and the pack contract change. The tool output does not change: it shows the actual `parentId`, and the activity log records `parentAdjusted`. - Migration `0434` adds a nullable column, a foreign key and an index. It does not backfill rows. - `IssueCreationSource.run` is now nullable. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edits, local test runs). ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
485 lines
21 KiB
TypeScript
485 lines
21 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { agents, companies, createDb, heartbeatRuns, issueRelations, issues } from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { issueService } from "../services/issues.ts";
|
|
import { planIssueParentChange, resolveNewTaskParent } from "../services/issue-hierarchy.ts";
|
|
import { resolveIssueCreationSource } from "../services/issue-creation-source.ts";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeEmbeddedPostgres("agent task hierarchy depth", () => {
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
let db: ReturnType<typeof createDb>;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-task-depth-");
|
|
db = createDb(tempDb.connectionString);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedCompany() {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `D${companyId.replace(/-/g, "").slice(0, 5).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
const agentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Coder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
return { companyId, agentId };
|
|
}
|
|
|
|
async function seedIssue(
|
|
companyId: string,
|
|
values: Partial<typeof issues.$inferInsert> = {},
|
|
) {
|
|
const id = randomUUID();
|
|
await db.insert(issues).values({
|
|
id,
|
|
companyId,
|
|
title: `Task ${id.slice(0, 6)}`,
|
|
status: "todo",
|
|
priority: "medium",
|
|
...values,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
async function seedTree(companyId: string) {
|
|
const suffix = randomUUID().slice(0, 8);
|
|
const rootIdentifier = `ROOT-${suffix}`;
|
|
const childIdentifier = `CHILD-${suffix}`;
|
|
const rootId = await seedIssue(companyId, { identifier: rootIdentifier });
|
|
const childId = await seedIssue(companyId, { parentId: rootId, identifier: childIdentifier });
|
|
return { rootId, childId, rootIdentifier, childIdentifier };
|
|
}
|
|
|
|
it("places an agent's child-of-a-child beside the requester and keeps it as the origin", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { rootId, childId, rootIdentifier, childIdentifier } = await seedTree(companyId);
|
|
|
|
// expectedParentId: the route authorized the placed ancestor.
|
|
const created = await issueService(db).create(companyId, {
|
|
title: "Follow-up from the child",
|
|
parentId: childId,
|
|
expectedParentId: rootId,
|
|
});
|
|
|
|
expect(created.parentId).toBe(rootId);
|
|
expect(created.originIssueId).toBe(childId);
|
|
expect(created.privacyParentIssueId).toBe(childId);
|
|
expect(created.parentAdjusted).toEqual({
|
|
reason: "max_agent_depth",
|
|
applied: true,
|
|
requestedParent: { id: childId, identifier: childIdentifier },
|
|
effectiveParent: { id: rootId, identifier: rootIdentifier },
|
|
});
|
|
});
|
|
|
|
it("lets board users create grandchildren", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { childId } = await seedTree(companyId);
|
|
|
|
const created = await issueService(db).create(companyId, {
|
|
title: "Board grandchild",
|
|
parentId: childId,
|
|
parentDepthPolicy: "allow_nested",
|
|
});
|
|
|
|
expect(created.parentId).toBe(childId);
|
|
expect(created.originIssueId).toBe(childId);
|
|
expect(created.parentAdjusted).toBeUndefined();
|
|
});
|
|
|
|
it("creates direct children of a top-level task unchanged", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { rootId } = await seedTree(companyId);
|
|
|
|
const created = await issueService(db).create(companyId, { title: "Second child", parentId: rootId });
|
|
|
|
expect(created.parentId).toBe(rootId);
|
|
expect(created.originIssueId).toBe(rootId);
|
|
expect(created.parentAdjusted).toBeUndefined();
|
|
});
|
|
|
|
it("records the creating run's task as the origin of a top-level task", async () => {
|
|
const { companyId, agentId } = await seedCompany();
|
|
const sourceId = await seedIssue(companyId);
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: sourceId });
|
|
|
|
const created = await issueService(db).create(companyId, {
|
|
title: "Standalone follow-up",
|
|
parentId: null,
|
|
createdByAgentId: agentId,
|
|
actorRunId: runId,
|
|
});
|
|
|
|
expect(created.parentId).toBeNull();
|
|
expect(created.originIssueId).toBe(sourceId);
|
|
expect(created.originRunId).toBe(runId);
|
|
});
|
|
|
|
it("flattens legacy deep trees to their root", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { rootId, childId } = await seedTree(companyId);
|
|
const grandchildId = await seedIssue(companyId, { parentId: childId });
|
|
|
|
const created = await issueService(db).create(companyId, { title: "Under legacy grandchild", parentId: grandchildId, expectedParentId: rootId });
|
|
|
|
expect(created.parentId).toBe(rootId);
|
|
expect(created.originIssueId).toBe(grandchildId);
|
|
expect(created.parentAdjusted?.requestedParent?.id).toBe(grandchildId);
|
|
});
|
|
|
|
it("blocks the requester, not the root, when a flattened child should block its parent", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { rootId, childId } = await seedTree(companyId);
|
|
|
|
const { issue, parentBlockerAdded } = await issueService(db).createChild(childId, {
|
|
title: "Blocking follow-up",
|
|
blockParentUntilDone: true,
|
|
expectedParentId: rootId,
|
|
});
|
|
|
|
expect(issue.parentId).toBe(rootId);
|
|
expect(issue.originIssueId).toBe(childId);
|
|
expect(issue.parentAdjusted?.reason).toBe("max_agent_depth");
|
|
expect(parentBlockerAdded).toBe(true);
|
|
const blockers = await db
|
|
.select({ relatedIssueId: issueRelations.relatedIssueId })
|
|
.from(issueRelations)
|
|
.where(and(eq(issueRelations.issueId, issue.id), eq(issueRelations.type, "blocks")));
|
|
expect(blockers.map((row) => row.relatedIssueId)).toEqual([childId]);
|
|
});
|
|
|
|
it("reuses a flattened child for the same idempotency key", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { childId } = await seedTree(companyId);
|
|
const idempotencyKey = `flatten-${randomUUID()}`;
|
|
|
|
const first = await issueService(db).createChild(childId, { title: "Once", idempotencyKey });
|
|
const second = await issueService(db).createChild(childId, { title: "Once", idempotencyKey });
|
|
|
|
expect(second.issue.id).toBe(first.issue.id);
|
|
});
|
|
|
|
it("places under the real root of a legacy tree deeper than 64 levels", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const rootId = await seedIssue(companyId);
|
|
let deepestId = rootId;
|
|
for (let level = 1; level < 70; level += 1) deepestId = await seedIssue(companyId, { parentId: deepestId });
|
|
|
|
const created = await issueService(db).create(companyId, { title: "From the 70th level", parentId: deepestId, expectedParentId: rootId });
|
|
|
|
expect(created.parentId).toBe(rootId);
|
|
expect(created.originIssueId).toBe(deepestId);
|
|
});
|
|
|
|
it("does not move a task above a low-trust boundary root", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const rootId = await seedIssue(companyId);
|
|
const boundaryId = randomUUID();
|
|
await seedIssue(companyId, {
|
|
id: boundaryId,
|
|
parentId: rootId,
|
|
executionPolicy: {
|
|
authorizationPolicy: {
|
|
trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: boundaryId },
|
|
},
|
|
},
|
|
});
|
|
|
|
const placement = await resolveNewTaskParent(db, companyId, boundaryId, "flatten");
|
|
|
|
expect(placement).toEqual({ effectiveParentId: boundaryId, adjustment: null });
|
|
});
|
|
|
|
it("falls back to top level when a legacy tree is rooted at a conversation", async () => {
|
|
const { companyId, agentId } = await seedCompany();
|
|
const conversationId = await seedIssue(companyId, { conversationAgentId: agentId, conversationUserId: "user-1", conversationState: "active", assigneeAgentId: agentId,
|
|
});
|
|
const legacyChildId = await seedIssue(companyId, { parentId: conversationId });
|
|
|
|
const created = await issueService(db).create(companyId, { title: "From legacy chat child", parentId: legacyChildId });
|
|
|
|
expect(created.parentId).toBeNull();
|
|
expect(created.originIssueId).toBe(legacyChildId);
|
|
expect(created.parentAdjusted).toMatchObject({ reason: "max_agent_depth", effectiveParent: null });
|
|
});
|
|
|
|
it("rejects a create whose locked placement differs from the authorized one", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { rootId, childId } = await seedTree(companyId);
|
|
const otherRootId = await seedIssue(companyId);
|
|
// The route authorized placement under rootId; the child then moved.
|
|
await db.update(issues).set({ parentId: otherRootId }).where(eq(issues.id, childId));
|
|
|
|
await expect(issueService(db).create(companyId, {
|
|
title: "Stale placement",
|
|
parentId: childId,
|
|
expectedParentId: rootId,
|
|
})).rejects.toMatchObject({ status: 409 });
|
|
});
|
|
|
|
it("dedupes recent titles per requester, not across siblings placed under the same root", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { rootId, childId } = await seedTree(companyId);
|
|
const otherChildId = await seedIssue(companyId, { parentId: rootId });
|
|
|
|
const first = await issueService(db).create(companyId, { title: "Write tests", parentId: childId, allowDuplicate: false });
|
|
const second = await issueService(db).create(companyId, { title: "Write tests", parentId: otherChildId, allowDuplicate: false });
|
|
const repeat = await issueService(db).create(companyId, { title: "Write tests", parentId: childId, allowDuplicate: false });
|
|
|
|
expect(second.id).not.toBe(first.id);
|
|
expect(second.originIssueId).toBe(otherChildId);
|
|
expect(repeat.id).toBe(first.id);
|
|
});
|
|
|
|
it("ignores caller-supplied originIssueId", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { childId } = await seedTree(companyId);
|
|
const forgedId = await seedIssue(companyId);
|
|
|
|
const created = await issueService(db).create(companyId, {
|
|
title: "Provenance is server-derived",
|
|
parentId: childId,
|
|
...({ originIssueId: forgedId } as Record<string, unknown>),
|
|
});
|
|
|
|
expect(created.originIssueId).toBe(childId);
|
|
});
|
|
|
|
it("rejects a parent from another company on create and move", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { companyId: otherCompanyId } = await seedCompany();
|
|
const foreignParentId = await seedIssue(otherCompanyId);
|
|
|
|
await expect(issueService(db).create(companyId, { title: "Foreign parent", parentId: foreignParentId }))
|
|
.rejects.toMatchObject({ status: 404 });
|
|
await expect(issueService(db).create(companyId, {
|
|
title: "Foreign parent, board", parentId: foreignParentId, parentDepthPolicy: "allow_nested",
|
|
})).rejects.toMatchObject({ status: 404 });
|
|
|
|
const movingId = await seedIssue(companyId);
|
|
await expect(issueService(db).update(movingId, { parentId: foreignParentId, parentDepthPolicy: "allow_nested" }))
|
|
.rejects.toMatchObject({ status: 404 });
|
|
});
|
|
|
|
it("originates from the creating run's task, not the named parent, in detail and lists", async () => {
|
|
const { companyId, agentId } = await seedCompany();
|
|
const { childId } = await seedTree(companyId);
|
|
const runTaskId = await seedIssue(companyId);
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: runTaskId });
|
|
|
|
const created = await issueService(db).create(companyId, {
|
|
title: "Named parent from another run", parentId: childId, createdByAgentId: agentId, actorRunId: runId,
|
|
});
|
|
// A run working on runTask filed this under childId: it originates from runTask.
|
|
expect(created.originIssueId).toBe(runTaskId);
|
|
expect(created.privacyParentIssueId).toBe(childId);
|
|
|
|
const fromRunTask = await issueService(db).list(companyId, { createdFromIssueId: runTaskId });
|
|
const fromChild = await issueService(db).list(companyId, { createdFromIssueId: childId });
|
|
expect(fromRunTask.map((row) => row.id)).toContain(created.id);
|
|
expect(fromChild.map((row) => row.id)).not.toContain(created.id);
|
|
expect((await resolveIssueCreationSource(db, created))?.sourceIssue.id).toBe(runTaskId);
|
|
});
|
|
|
|
it("originates from a legacy run bound only by a task identifier", async () => {
|
|
const { companyId, agentId } = await seedCompany();
|
|
const { childId } = await seedTree(companyId);
|
|
const identifier = `LEG-${randomUUID().slice(0, 6)}`;
|
|
const runTaskId = await seedIssue(companyId, { identifier });
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { taskKey: identifier } });
|
|
|
|
const created = await issueService(db).create(companyId, {
|
|
title: "From a legacy-bound run", parentId: childId, createdByAgentId: agentId, actorRunId: runId,
|
|
});
|
|
|
|
expect(created.originIssueId).toBe(runTaskId);
|
|
});
|
|
|
|
it("makes unconfirmed placements standalone instead of writing under an unchecked ancestor", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { childId } = await seedTree(companyId);
|
|
|
|
// Internal callers authorize only the parent they request.
|
|
const created = await issueService(db).create(companyId, { title: "Internal follow-up", parentId: childId });
|
|
expect(created.parentId).toBeNull();
|
|
expect(created.privacyParentIssueId).toBe(childId);
|
|
expect(created.originIssueId).toBe(childId);
|
|
expect(created.parentAdjusted).toMatchObject({ reason: "max_agent_depth", applied: true, effectiveParent: null });
|
|
|
|
const { issue } = await issueService(db).createChild(childId, { title: "Internal child helper" });
|
|
expect(issue.parentId).toBeNull();
|
|
|
|
const movingId = await seedIssue(companyId);
|
|
const moved = await issueService(db).update(movingId, { parentId: childId, title: "Renamed anyway" });
|
|
expect(moved?.parentId).toBeNull();
|
|
expect(moved?.title).toBe("Renamed anyway");
|
|
expect(moved?.parentAdjusted).toMatchObject({ reason: "max_agent_depth", applied: false, effectiveParent: null });
|
|
|
|
// An already-parented task keeps its parent, and the notice names it.
|
|
const otherRootId = await seedIssue(companyId, { identifier: `KEEP-${randomUUID().slice(0, 6)}` });
|
|
const parentedId = await seedIssue(companyId, { parentId: otherRootId });
|
|
const kept = await issueService(db).update(parentedId, { parentId: childId });
|
|
expect(kept?.parentId).toBe(otherRootId);
|
|
expect(kept?.parentAdjusted).toMatchObject({ applied: false, effectiveParent: { id: otherRootId } });
|
|
});
|
|
|
|
it("does not expose a private origin on a task its readers may see", async () => {
|
|
const { companyId, agentId } = await seedCompany();
|
|
const openParentId = await seedIssue(companyId);
|
|
const privateTaskId = randomUUID();
|
|
await seedIssue(companyId, { id: privateTaskId, visibility: "private", privacyRootIssueId: privateTaskId });
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: privateTaskId });
|
|
|
|
const created = await issueService(db).create(companyId, {
|
|
title: "Open work filed from a private task", parentId: openParentId, createdByAgentId: agentId, actorRunId: runId,
|
|
});
|
|
|
|
expect(created.visibility).toBe("open");
|
|
expect(created.originIssueId).toBeNull();
|
|
// The audit link stays server-side through the run, checked for access on read.
|
|
expect(created.originRunId).toBe(runId);
|
|
expect((await resolveIssueCreationSource(db, created))?.sourceIssue.id).toBe(privateTaskId);
|
|
});
|
|
|
|
it("does not expose a hidden origin or a private sibling's id", async () => {
|
|
const { companyId, agentId } = await seedCompany();
|
|
const runOn = async (taskId: string) => {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: taskId });
|
|
return runId;
|
|
};
|
|
|
|
const openParentId = await seedIssue(companyId);
|
|
const hiddenTaskId = await seedIssue(companyId, { hiddenAt: new Date() });
|
|
const fromHidden = await issueService(db).create(companyId, {
|
|
title: "From a hidden task", parentId: openParentId, createdByAgentId: agentId, actorRunId: await runOn(hiddenTaskId),
|
|
});
|
|
expect(fromHidden.originIssueId).toBeNull();
|
|
|
|
const rootId = randomUUID();
|
|
await seedIssue(companyId, { id: rootId, visibility: "private", privacyRootIssueId: rootId });
|
|
const siblingA = await seedIssue(companyId, { parentId: rootId, visibility: "private", privacyRootIssueId: rootId });
|
|
const siblingB = await seedIssue(companyId, { parentId: rootId, visibility: "private", privacyRootIssueId: rootId });
|
|
const fromSibling = await issueService(db).create(companyId, {
|
|
title: "Filed under a private sibling", parentId: siblingB, createdByAgentId: agentId,
|
|
actorRunId: await runOn(siblingA), expectedParentId: rootId,
|
|
});
|
|
expect(fromSibling.privacyParentIssueId).toBe(siblingB);
|
|
expect(fromSibling.originIssueId).toBeNull();
|
|
expect((await resolveIssueCreationSource(db, fromSibling))?.sourceIssue.id).toBe(siblingA);
|
|
});
|
|
|
|
describe("moves", () => {
|
|
it("keeps the requested private parent as the privacy source of a placed move", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const rootId = await seedIssue(companyId);
|
|
const privateChildId = randomUUID();
|
|
await seedIssue(companyId, { id: privateChildId, parentId: rootId, visibility: "private", privacyRootIssueId: privateChildId });
|
|
const movingId = await seedIssue(companyId);
|
|
|
|
const updated = await issueService(db).update(movingId, { parentId: privateChildId, expectedParentId: rootId });
|
|
|
|
expect(updated?.parentId).toBe(rootId);
|
|
expect(updated?.privacyParentIssueId).toBe(privateChildId);
|
|
expect(updated?.visibility).toBe("private");
|
|
expect(updated?.privacyRootIssueId).toBe(privateChildId);
|
|
});
|
|
|
|
it("rejects a move whose placement differs from the authorized one", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { childId } = await seedTree(companyId);
|
|
const movingId = await seedIssue(companyId);
|
|
|
|
await expect(issueService(db).update(movingId, { parentId: childId, expectedParentId: childId }))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
});
|
|
|
|
it("places an agent move into a child beside it", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { rootId, childId } = await seedTree(companyId);
|
|
const movingId = await seedIssue(companyId);
|
|
|
|
const updated = await issueService(db).update(movingId, { parentId: childId, expectedParentId: rootId });
|
|
|
|
expect(updated?.parentId).toBe(rootId);
|
|
expect(updated?.parentAdjusted).toMatchObject({ reason: "max_agent_depth", applied: true });
|
|
});
|
|
|
|
it("skips only the parent change when the moved task has children", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { rootId } = await seedTree(companyId);
|
|
const movingId = await seedIssue(companyId);
|
|
await seedIssue(companyId, { parentId: movingId });
|
|
|
|
const updated = await issueService(db).update(movingId, { parentId: rootId, title: "Renamed" });
|
|
|
|
expect(updated?.parentId).toBeNull();
|
|
expect(updated?.title).toBe("Renamed");
|
|
expect(updated?.parentAdjusted).toMatchObject({
|
|
reason: "has_children",
|
|
applied: false,
|
|
requestedParent: { id: rootId },
|
|
effectiveParent: null,
|
|
});
|
|
});
|
|
|
|
it("lets board users move to any depth", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { childId } = await seedTree(companyId);
|
|
const movingId = await seedIssue(companyId);
|
|
await seedIssue(companyId, { parentId: movingId });
|
|
|
|
const plan = await planIssueParentChange(
|
|
db,
|
|
{ id: movingId, companyId, parentId: null },
|
|
childId,
|
|
"allow_nested",
|
|
);
|
|
expect(plan).toEqual({ parentId: childId, adjustment: null });
|
|
|
|
const updated = await issueService(db).update(movingId, { parentId: childId, parentDepthPolicy: "allow_nested" });
|
|
expect(updated?.parentId).toBe(childId);
|
|
expect(updated?.parentAdjusted).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("resolves creation provenance from the recorded origin task", async () => {
|
|
const { companyId } = await seedCompany();
|
|
const { childId } = await seedTree(companyId);
|
|
const created = await issueService(db).create(companyId, { title: "Flattened", parentId: childId });
|
|
|
|
const source = await resolveIssueCreationSource(db, created);
|
|
|
|
expect(source?.sourceIssue.id).toBe(childId);
|
|
expect(source?.run).toBeNull();
|
|
});
|
|
});
|