mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server has about 1,100 test and helper files in `server/src/__tests__` > - `server/tsconfig.json` excludes that directory, Vitest does not check types, and no script runs the tsconfig in that directory > - So a test can call a function with the wrong arguments and still pass, and no tool reports it > - This gap hid 2,168 real type errors, and some of them hid tests that did not test what they claim > - This pull request fixes all of these errors and adds the directory to the server layers from paperclipai/paperclip#18737 > - The benefit is that a type error in a server test now fails the typecheck, with no baseline and no backlog ## Linked Issues or Issue Description Refs paperclipai/paperclip#18737, which is merged and added the server layers. Refs paperclipai/paperclip#18738, which is merged and checks the test projects outside `pnpm -r`, so it is not a duplicate. Refs paperclipai/paperclip#18137, which records that this directory has no check. **What happened?** No tool checked the types of 1,074 of the 1,101 files in `server/src/__tests__`. I searched `package.json`, the workflows, and all tsconfig files. 27 files are in the program of another tsconfig, and these 27 have 0 errors. 11 of the 27 are in `helpers/maintenance/tsconfig.json`. No script ran that project when I measured this. paperclipai/paperclip#18738 now runs it. **Expected behavior** The server typecheck checks these files, and a type error fails the check. **Steps to reproduce** 1. On the base commit, run `tsc -p server/src/__tests__/tsconfig.json --noEmit`. It reports 6,536 errors and needs 12.3 GiB. 2. Add a type error to any file in `server/src/__tests__`. 3. Run `pnpm --filter @paperclipai/server typecheck`. It passes. **Paperclip version or commit** I measured the error breakdown below on commit `8dfd632452`, with TypeScript 7.0.2. The branch now includes master up to paperclipai/paperclip#18865. **Deployment mode** Not applicable. This issue applies to development and CI. No production source file in the server changes. ### Error breakdown Each row adds one change to the row above. | Step | Total errors | Change | Cause | | --- | --- | --- | --- | | Config as committed | 6,536 | | | | Add `src/types/*.d.ts` | 4,447 | -2,089 | Config. The Express request types were missing. | | Set `rootDir` to the repository root | 2,436 | -2,011 | Config. | | Set `allowImportingTsExtensions` | 2,181 | -255 | Config. Vitest accepts `.ts` imports. | | Add `ES2024.Promise` to `lib` | 2,138 | -43 | Config. Node 24 has `Promise.withResolvers`. | | Set `allowJs` for `.mjs` imports | 2,148 | +10 | Config. It removes 8 `TS7016` errors and shows new real errors. | | Set `declaration` to false | 2,142 | -6 | Config. Tests emit nothing. | | Count only files in `server/src` | 2,058 | -84 | Other packages: 53 Daytona, 29 Runner test, 2 UI. | So 4,478 of the 6,536 errors (69%) came from config or scope, and 2,058 are real type errors in 314 files. The 29 Runner test errors are also real, which gives 2,087 errors. Tests that master added after this measurement had 81 more errors, so this pull request fixes 2,168 errors. This table classifies the first 2,058 errors. | Kind of real error | Errors | | --- | --- | | Wrong argument or assignment type | 1,023 | | Stale or partial fixture objects | 525 | | Property that the type does not have | 317 | | Missing narrowing of null or unknown | 101 | | Implicit any | 60 | | Missing name or module | 16 | | Other | 16 | ### What the type errors hid Most errors were stale fixtures and loose mocks. These errors hid real test problems, and this pull request fixes them: - **A test that never ran.** `instrumentation.test.ts` required `sdk-trace-base`, which is not installed. So 3 span tests were skipped. They now run and pass. - **Assertions that could not fail.** - `issue-thread-interactions-service` used a company ID as an issue ID. - `status-card-evaluation-work` compared `[]` with `[]` in a rollback check. - `slack-conversation-lifecycle` read `item.issueId`, which does not exist. - `email-channels` used a permission key that does not exist. - `heartbeat-project-env` imported `LOW_TRUST_REVIEW_PRESET`, which is not exported. The value was undefined. - **Postgres processes that did not stop.** 11 test files called `stop` on the test database helper. That method does not exist, and the helper method is `cleanup`. - **Arguments that the service ignored.** - `methodKey` is now `connectionMethodKey` in the Daytona, Unikraft, 1Password, and Railway tests. - 108 calls passed `"owner-user"` to a parameter that expects an options object. - 9 chat wizard fixtures passed `managementContext` as `source`, so they ran `existing_app` and not `manifest`. - 2 tests passed a `{ userId }` actor that the function ignores. - Inserts into `environments` passed a `companyId` column that the table does not have. - **Problems in tests that master added during this work.** - `sandbox-creation-operations` read `database.connectionString` when an external test database was set. In that mode `database` is undefined, so the fixture would stop with a TypeError. It now uses the external URL. - `idle-completed-history` set a Dot operation to `dispatched`. That is a Muse status, and the Dot type does not have it. The fixture now uses `admitted`, which is a real in-flight Dot status. ### Possible product problems, not changed These may be real problems in production code or types. I did not change them, because this pull request does not change production behavior: - `decisions-service` `service.create` does not validate the lenient `cancel_issue_tree` input. - `createConnection` ignores `input.connectionPurpose`. - The `createConnection` route schema default stores `{}` for `transportConfig`. - `prepareManagedAiRuntime` accepted the invalid value `mode: "connection"`. - `createBinding` accepts invalid `projectionClass` strings outside class 3. - `ensureManagedProjectWorkspace` forwards an untyped `repoRef` by spread. - `routineVariableSchema.label` is optional, but `RoutineVariable.label` is required. - `ToolRunContext.projectId` may be too strict. - `withdrawInteraction` reads `issue.status`, which its type omits. - `issueService.list` and `reconcileNativeFinalizations` omit fields that they return at runtime. - `createGitHubExternalObjectProvider` handles `tokenProvider: null`, but its type forbids this value. ## What Changed - Fixed the 2,168 type errors. The diff changes 322 test and helper files. - Corrected `server/src/__tests__/tsconfig.json`. Editors and the typecheck now read the same options and report the same errors. - Added three `test-tree` layers to `server/scripts/typecheck-layers.mjs`. They read the server layers as declarations, and any error fails them. A hash of the path selects the shard, so a new file does not move other files. - Added `server/scripts/typecheck-stubs/daytona-sdk.d.ts`. Server tests reach Daytona plugin source, and the root install does not have `@daytonaio/sdk`. - Changed two imports in `ui/src/lib/issue-execution-policy.ts` to use `.js` extensions, because server tests import this file. - Made `declarationsConfig` and `--declarations-only` skip the `test-tree` layers, because these layers emit nothing. Other projects read only the source layers. Added a test for this. - Updated `doc/DEVELOPING.md`. ## Verification Run these commands: ```sh pnpm --filter @paperclipai/server typecheck pnpm exec vitest run server/scripts/typecheck-layers.test.mjs ``` Results on macOS (arm64), Node 24, 16 cores. Each check ran after the merge of paperclipai/paperclip#18738 unless the row says otherwise: | Check | Commit | Result | | --- | --- | --- | | Server typecheck, `PAPERCLIP_TYPECHECK_LOW_MEMORY=1`, all 7 layers | `6e45a08573` | Exit 0, 0 errors, 62 seconds, peak 3.52 GiB | | `node scripts/typecheck-test-projects.mjs --low-memory` | `a3306bd13e` | Exit 0, 0 errors, 42 seconds, peak 3.25 GiB | | The same script with `--reuse-declarations` | `1b878aabc0` | Exit 0, 0 errors | | `typecheck-layers.test.mjs` and `live-recovery-work.test.ts` | `01db5634e4` | 90 passed. These include real compiler runs for a pass, a type error, and an invalid tsconfig. | | `tsconfig.declarations.json` after a full server typecheck | `0fb1e4fb0a` | It references only `below`, `cycle`, and `above`. No generated config names a `tsconfig.layers.json` file. | | Owned runtime test in `idle-completed-history` | `e8f2f448ac` | Passed, with the fix that master later replaced | | `idle-incoming-wake`, `plugin-idle-manager`, and the changed `chat-channels` test | `6e45a08573` | 30 passed | | Runner `native-execution.test.ts` | `eab4dcc1f4` | 114 passed | | The 19 server test files that changed since `eab4dcc1f4`, and `typecheck-layers.test.mjs` | `259737b700` | 4,275 of 4,277 passed. The 2 failures are in `chat-channels` and are in the list of 7 below. | The peak is the largest single process, from `/usr/bin/time -l`. Peaks for the server typecheck were between 3.42 GiB and 3.56 GiB across 8 runs. Peaks for the test projects were 3.25 GiB and 3.37 GiB across 2 runs. I did not measure a cgroup. **Runtime check of the fixes.** Types do not prove that a test still works. So I ran all 309 changed server test files, 11,929 tests, before the rebase onto paperclipai/paperclip#18737: | Result | Tests | Detail | | --- | --- | --- | | Passed | 11,913 | Includes the 2 tests and the 54 `dot-runner` tests that passed in a later run, as described below. | | Failed | 7 | The same 7 tests also fail on the commit before the fixes. | | Skipped | 9 | These tests have their own skip conditions: `sentry` 5, and 1 each in `bundled-plugin-registration`, `customer-success-cloud`, `onepassword-connection`, and `workspace-runtime-https-live-exercise`. | The 7 tests that fail with and without this change, on my machine: - 3 tests in `chat-channels` and `email-channels` stop with `ENOENT` on a `SKILL.md` file. The path is in the parent checkout, above the git worktree that I used, so I expect that the worktree is the cause. I did not prove this. - `workspace-instance-cleanup` compares `/private/var/...` with `/var/...`. This is a macOS symlink difference. - 2 tests in `git-reproducible-managed-checkouts` get `unknown_base_git_files` and expect `unpushed_managed_checkout`. I did not find the cause. - 1 more test in `chat-channels`, `materializes direct external-chat finals once`, gets `undefined`. I did not find the cause. CI is the check for these 7 tests. `ci / verify` passed on `eab4dcc1f4`. Failures during this work: - My fix to `agent-runtime-connector-access` changed an invalid permission key in the setup, but not in the assertion. The runtime check found this. I corrected the assertion, and the file passes. - `heartbeat-plugin-environment` failed one wait in the full run. It passed three times alone, so I think that load caused the failure. It can fail again under load. - `dot-runner` skipped all 54 tests in the full run because `cargo` was not on my `PATH`. It then passed 54 of 54 with the toolchain on the `PATH`. - My first `lib` value removed the default DOM types, and the error count went up by 375. I added the DOM libraries. - My Daytona stub with bare `any` left 48 errors, because generic helpers then inferred `unknown`. I added shapes that return promises. - One fix to `agent-secrets-routes` used `class_3_static_lease`, and the test failed. That class allows only specific paths. I reverted to `unclassified`. - `ci / General tests (server (8/12))` failed on `259737b700` because of the flaky port in `idle-completed-history`. paperclipai/paperclip#18853 fixed this test on master, so this pull request keeps that fix. **Audit of the diff.** I counted these patterns in added and removed lines, to find fixes that only hide an error: | Pattern | Added | Removed | | --- | --- | --- | | `@ts-ignore` | 0 | 0 | | `@ts-expect-error` | 0 | 1 | | `.skip` | 0 | 0 | | `as any` | 9 | 9 | | `: any` | 11 | 2 | | `as never` | 8 | 2 | | `as unknown as` | 35 | 4 | | `Promise<unknown>` | 16 | 5 | | `expect(` | 128 | 113 | No hunk removes more `expect(` calls than it adds. Three removals of test content were intentional, and I checked each one: - `muse-control` removed `signalToken`. Line 1012 already asserts that it is absent. - `merged-pr-confirmation-sweep` removed legacy aliases. Other tests cover them. - `heartbeat-workspace-branch-containment` removed a `finalize` branch that no test row can reach. Some partial fixtures stay unchecked through `as unknown as` and `Promise<unknown>`, mostly in `issue-agent-mutation-ownership-routes` and `issue-comment-reopen-routes`. **Accuracy of the check.** One program over the tree and the three shards reported the same errors at the same positions. Memory, as the peak of the largest process on one thread, was 3.93 GiB for one program and 2.43 GiB for the largest shard. I measured these two values before the fixes. Not run or not proven: - I did not run the full `pnpm test:run`, or a root `pnpm build`. I expect CI to run the full suites. - I did not run the 4 GiB container check, or the full `scripts/typecheck-low-memory.mjs`. The peaks above come from macOS, so they are not a Linux cgroup measurement. - I did not run the UI typecheck after the merges. - `workspace-runtime-https-live-exercise` is skipped, so its `cleanup` fix did not run. ## Risks - **A large diff in test files.** 328 files change. A pull request that edits the same tests can get merge conflicts. New tests on master can also add type errors before this merges. Any such error fails this check and needs a fix here. - **A higher cost for changes to source types.** A change to a type can add errors in test files that the author did not touch. The author must then fix them. This is the purpose of the check, but it is new work. - **Changed test behavior.** Some tests now run paths that they did not run before, such as the 9 chat wizard fixtures and the 3 span tests. They pass here, but they can be more sensitive to timing or to the environment. - **The Daytona stub can go stale.** The stub is written by hand. If the plugin uses a new SDK member, the typecheck reports an error until someone updates the stub. - **The release path.** `release-verify.yml` and `scripts/release.sh` run `pnpm -r typecheck`, so they now run this check. - **The tsconfig change.** The test transform reads `server/src/__tests__/tsconfig.json`. The options that I changed are for type checks, and the runs above passed. - **A gap that remains.** Tsconfigs in `packages/paperclip-runner` and in most sandbox provider plugins exclude `*.test.ts`. This check does not read most of those test files, so they can still drift. That work is separate from this pull request. > 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 Anthropic Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with reasoning, tool use, code execution, and subagents. The runtime does not show the context window size. ## 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 - [ ] I have run tests locally and they pass (7 tests fail locally, with and without this change. See Verification.) - [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 - [x] 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>
7005 lines
222 KiB
TypeScript
7005 lines
222 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { asc, eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { sql } from "drizzle-orm";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
companies,
|
|
companyMemberships,
|
|
createDb,
|
|
documentRevisions,
|
|
documents,
|
|
environments,
|
|
executionWorkspaces,
|
|
goals,
|
|
heartbeatRuns,
|
|
instanceSettings,
|
|
issueComments,
|
|
issueInboxArchives,
|
|
issueDocuments,
|
|
issueAccessGrants,
|
|
issuePlanDecompositions,
|
|
issueReadStates,
|
|
issueRecoveryActions,
|
|
issueRelations,
|
|
issueThreadInteractions,
|
|
issueWorkProducts,
|
|
issues,
|
|
projectWorkspaces,
|
|
projectAccessMembers,
|
|
projects,
|
|
workspaceOperations,
|
|
toolApplications,
|
|
toolConnections,
|
|
toolOauthStates,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
|
|
import { instanceSettingsService } from "../services/instance-settings.ts";
|
|
import {
|
|
clampIssueListLimit,
|
|
deriveIssueCommentRunLogAttribution,
|
|
MAX_CHILD_ISSUES_CREATED_BY_HELPER,
|
|
ISSUE_LIST_MAX_LIMIT,
|
|
issueService,
|
|
readIssueCommentRunLogText,
|
|
} from "../services/issues.ts";
|
|
import { getRunLogStore } from "../services/run-log-store.js";
|
|
import * as nativeRunLogRecovery from "../services/native-runtime/native-run-log-recovery.js";
|
|
import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
|
|
import { settleUnrecoverableExecutions } from "../services/execution-recovery-resolution.js";
|
|
import {
|
|
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
|
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
|
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
|
} from "../services/execution-workspace-policy.ts";
|
|
import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH, type IssueWorkMode } from "@paperclipai/shared";
|
|
|
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
async function seedIssueCompany(db: ReturnType<typeof createDb>, companyId: string) {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
}
|
|
|
|
async function seedWorkspaceProject(db: ReturnType<typeof createDb>, companyId: string, projectId: string) {
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
}
|
|
|
|
async function seedCodexCoder(db: ReturnType<typeof createDb>, companyId: string, agentId: string) {
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
// list() adds these fields at runtime. The declared return type collapses the
|
|
// union of its result shapes to the base row.
|
|
type IssueListRow = Awaited<ReturnType<ReturnType<typeof issueService>["list"]>>[number] & {
|
|
lastActivityAt?: Date | null;
|
|
blockedBy?: IssueRelationIssueSummary[];
|
|
blockerAttention?: IssueBlockerAttention;
|
|
descriptionTruncated?: boolean;
|
|
};
|
|
|
|
describe("issue list limit helpers", () => {
|
|
it("clamps untrusted issue-list limits to the server maximum", () => {
|
|
expect(clampIssueListLimit(0)).toBe(1);
|
|
expect(clampIssueListLimit(25.9)).toBe(25);
|
|
expect(clampIssueListLimit(ISSUE_LIST_MAX_LIMIT + 10)).toBe(ISSUE_LIST_MAX_LIMIT);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService run attachment artifacts", () => {
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("registers a run-produced attachment as an attachment-backed artifact work product", async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-attachment-artifact-");
|
|
const db = createDb(tempDb.connectionString);
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: "ART",
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "ArtifactAgent",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Artifact registration",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
});
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" });
|
|
|
|
const attachment = await issueService(db).createAttachment({
|
|
issueId,
|
|
issueCommentId: null,
|
|
provider: "local_disk",
|
|
objectKey: "issues/artifact/screenshot.png",
|
|
contentType: "image/png",
|
|
byteSize: 128,
|
|
sha256: "a".repeat(64),
|
|
originalFilename: "screenshot.png",
|
|
createdByAgentId: agentId,
|
|
createdByRunId: runId,
|
|
});
|
|
|
|
const artifact = await db
|
|
.select()
|
|
.from(issueWorkProducts)
|
|
.where(eq(issueWorkProducts.externalId, attachment.id))
|
|
.then((rows) => rows[0]);
|
|
expect(artifact).toMatchObject({
|
|
companyId,
|
|
issueId,
|
|
type: "artifact",
|
|
provider: "paperclip",
|
|
title: "screenshot.png",
|
|
createdByRunId: runId,
|
|
metadata: {
|
|
attachmentId: attachment.id,
|
|
contentType: "image/png",
|
|
byteSize: 128,
|
|
contentPath: `/api/attachments/${attachment.id}/content`,
|
|
openPath: `/api/attachments/${attachment.id}/content`,
|
|
downloadPath: `/api/attachments/${attachment.id}/content?download=1`,
|
|
originalFilename: "screenshot.png",
|
|
},
|
|
});
|
|
}, 20_000);
|
|
});
|
|
|
|
describe("readIssueCommentRunLogText", () => {
|
|
it("does not read after a timed-out native repair", async () => {
|
|
const repair = vi.spyOn(nativeRunLogRecovery, "recoverSettledNativeRunLog").mockImplementationOnce((_db, _store, _ref, _redaction, signal) => new Promise((_resolve, reject) => {
|
|
signal!.addEventListener("abort", () => reject(signal!.reason), { once: true });
|
|
}));
|
|
const read = vi.spyOn(getRunLogStore(), "read").mockResolvedValue({ content: "too late" });
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
const pending = readIssueCommentRunLogText({ logStore: "s3_segments", logRef: "company/agent/run", logBytes: null }, {} as ReturnType<typeof createDb>);
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
await expect(pending).resolves.toBe("");
|
|
expect(read).not.toHaveBeenCalled();
|
|
} finally {
|
|
await pending;
|
|
vi.useRealTimers();
|
|
repair.mockRestore();
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
it("keeps published evidence when a concurrent native repair wins ownership", async () => {
|
|
const repair = vi.spyOn(nativeRunLogRecovery, "recoverSettledNativeRunLog").mockRejectedValueOnce(new Error("The output journal is owned by another writer"));
|
|
const read = vi.spyOn(getRunLogStore(), "read").mockResolvedValueOnce({ content: "published evidence" });
|
|
try {
|
|
await expect(readIssueCommentRunLogText({ logStore: "s3_segments", logRef: "company/agent/run", logBytes: null }, {} as ReturnType<typeof createDb>)).resolves.toBe("published evidence");
|
|
expect(read).toHaveBeenCalledOnce();
|
|
} finally {
|
|
repair.mockRestore();
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
it("cancels timed-out storage reads so later listings can recover", async () => {
|
|
let active = 0;
|
|
const cleanups: Array<() => void> = [];
|
|
const read = vi.spyOn(getRunLogStore(), "read").mockImplementation((_handle, options) =>
|
|
new Promise((_resolve, reject) => {
|
|
active += 1;
|
|
let settled = false;
|
|
const abort = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
active -= 1;
|
|
reject(new DOMException("Read aborted", "AbortError"));
|
|
};
|
|
cleanups.push(abort);
|
|
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
}),
|
|
);
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null };
|
|
const firstBatch = Array.from({ length: 8 }, () => readIssueCommentRunLogText(run));
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(await Promise.all(firstBatch)).toEqual(Array(8).fill(""));
|
|
expect(active).toBe(0);
|
|
read.mockResolvedValueOnce({ content: "storage recovered" });
|
|
await expect(readIssueCommentRunLogText(run)).resolves.toBe("storage recovered");
|
|
expect(read).toHaveBeenCalledTimes(9);
|
|
} finally {
|
|
for (const cleanup of cleanups) cleanup();
|
|
await Promise.allSettled(firstBatch);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
vi.useRealTimers();
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("keeps readable attribution evidence for concurrent listings", async () => {
|
|
let release!: () => void;
|
|
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
const read = vi.spyOn(getRunLogStore(), "read").mockImplementation(async () => {
|
|
await gate;
|
|
return { content: "comment id: legacy-comment" };
|
|
});
|
|
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null };
|
|
const listings = Array.from({ length: 2 }, () =>
|
|
Promise.all(Array.from({ length: 8 }, () => readIssueCommentRunLogText(run))),
|
|
);
|
|
try {
|
|
release();
|
|
for (const listing of listings) {
|
|
expect(await listing).toEqual(Array(8).fill("comment id: legacy-comment"));
|
|
}
|
|
expect(read).toHaveBeenCalledTimes(16);
|
|
} finally {
|
|
release();
|
|
await Promise.allSettled(listings);
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([null, 128])("keeps partial attribution evidence when storage fails with logBytes=%s", async (logBytes) => {
|
|
const read = vi.spyOn(getRunLogStore(), "read").mockRejectedValue(new Error("Storage gateway unavailable"));
|
|
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes };
|
|
try {
|
|
await expect(readIssueCommentRunLogText(run)).resolves.toBe("");
|
|
read.mockResolvedValueOnce({ content: "earlier evidence", nextOffset: 16 });
|
|
await expect(readIssueCommentRunLogText(run)).resolves.toBe("earlier evidence");
|
|
} finally {
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("bounds reads that ignore cancellation and stops late pagination after the deadline", async () => {
|
|
let release!: (value: { content: string; nextOffset: number }) => void;
|
|
const stalled = new Promise<{ content: string; nextOffset: number }>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const read = vi.spyOn(getRunLogStore(), "read")
|
|
.mockResolvedValueOnce({ content: "earlier evidence", nextOffset: 16 })
|
|
.mockReturnValueOnce(stalled);
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
let result: string | undefined;
|
|
const pending = readIssueCommentRunLogText({
|
|
runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null,
|
|
}).then((value) => { result = value; });
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(result).toBe("earlier evidence");
|
|
expect(read.mock.calls[1]?.[1]?.signal?.aborted).toBe(true);
|
|
release({ content: "too late", nextOffset: 24 });
|
|
await pending;
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(read).toHaveBeenCalledTimes(2);
|
|
expect(result).toBe("earlier evidence");
|
|
} finally {
|
|
release({ content: "", nextOffset: 0 });
|
|
await pending.catch(() => {});
|
|
vi.useRealTimers();
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([null, 128, 0])("reads existing attribution markers with logBytes=%s", async (logBytes) => {
|
|
const commentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const read = vi.spyOn(getRunLogStore(), "read")
|
|
.mockResolvedValueOnce({ content: "comment id: ", nextOffset: 12 })
|
|
.mockResolvedValueOnce({ content: commentId + "\n" });
|
|
try {
|
|
const logContent = await readIssueCommentRunLogText({
|
|
runId, logStore: "local_file", logRef: "test/run.ndjson", logBytes,
|
|
});
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "local-board",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2020-01-01T00:00:01Z"),
|
|
}],
|
|
[{
|
|
runId,
|
|
agentId,
|
|
createdAt: new Date("2020-01-01T00:00:00Z"),
|
|
startedAt: new Date("2020-01-01T00:00:00Z"),
|
|
finishedAt: new Date("2020-01-01T00:00:02Z"),
|
|
logContent,
|
|
}],
|
|
);
|
|
if (logBytes === 0) {
|
|
expect(read).not.toHaveBeenCalled();
|
|
expect(derived.size).toBe(0);
|
|
} else {
|
|
expect(read).toHaveBeenCalledTimes(2);
|
|
expect(read.mock.calls[1]?.[1]?.offset).toBe(12);
|
|
expect(derived.get(commentId)).toEqual({
|
|
derivedAuthorAgentId: agentId,
|
|
derivedCreatedByRunId: runId,
|
|
derivedAuthorSource: "run_log_comment_post",
|
|
});
|
|
}
|
|
} finally {
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("deriveIssueCommentRunLogAttribution", () => {
|
|
it("recovers agent attribution from run logs that printed the posted comment id", () => {
|
|
const commentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "user-1",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2026-05-11T18:55:40.090Z"),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
runId,
|
|
agentId,
|
|
createdAt: new Date("2026-05-11T18:51:56.246Z"),
|
|
startedAt: new Date("2026-05-11T18:51:56.257Z"),
|
|
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
|
|
logContent: `comment id: ${commentId}\n`,
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.get(commentId)).toEqual({
|
|
derivedAuthorAgentId: agentId,
|
|
derivedCreatedByRunId: runId,
|
|
derivedAuthorSource: "run_log_comment_post",
|
|
});
|
|
});
|
|
|
|
it("resolves directly from the comment's own run id without reading logs", () => {
|
|
const commentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "local-board",
|
|
createdByRunId: runId,
|
|
createdAt: new Date("2026-05-11T18:55:40.090Z"),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
runId,
|
|
agentId,
|
|
createdAt: new Date("2026-05-11T18:51:56.246Z"),
|
|
startedAt: new Date("2026-05-11T18:51:56.257Z"),
|
|
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
|
|
logContent: "",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.get(commentId)).toEqual({
|
|
derivedAuthorAgentId: agentId,
|
|
derivedCreatedByRunId: runId,
|
|
derivedAuthorSource: "run_id",
|
|
});
|
|
});
|
|
|
|
it("does NOT attribute on run-window overlap alone — timing is not a lossless signal (option A)", () => {
|
|
// A human board comment can land inside an agent's run window; since both are
|
|
// stored as `local-board`, a timing-only guess would mis-attribute it. So a
|
|
// single overlapping run with no run-id and no log marker stays unresolved.
|
|
const commentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "local-board",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2026-05-11T18:55:40.090Z"),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
runId,
|
|
agentId,
|
|
createdAt: new Date("2026-05-11T18:51:56.246Z"),
|
|
startedAt: new Date("2026-05-11T18:51:56.257Z"),
|
|
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
|
|
logContent: "posted results without echoing the comment id",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.has(commentId)).toBe(false);
|
|
});
|
|
|
|
it("does not guess when multiple agent runs overlap and no log proves the author", () => {
|
|
const commentId = randomUUID();
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "local-board",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2026-05-11T18:55:40.090Z"),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
runId: randomUUID(),
|
|
agentId: randomUUID(),
|
|
createdAt: new Date("2026-05-11T18:51:56.246Z"),
|
|
startedAt: new Date("2026-05-11T18:51:56.257Z"),
|
|
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
|
|
logContent: "no comment id here",
|
|
},
|
|
{
|
|
runId: randomUUID(),
|
|
agentId: randomUUID(),
|
|
createdAt: new Date("2026-05-11T18:54:00.000Z"),
|
|
startedAt: new Date("2026-05-11T18:54:00.000Z"),
|
|
finishedAt: new Date("2026-05-11T18:56:00.000Z"),
|
|
logContent: "also nothing",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.has(commentId)).toBe(false);
|
|
});
|
|
|
|
it("does NOT attribute on same-agent run-window overlap alone (option A)", () => {
|
|
// Even when every overlapping run is the same agent, timing alone cannot
|
|
// prove the comment was the agent's vs a human board comment during the run.
|
|
const commentId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "local-board",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2026-06-29T17:41:59.916Z"),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
runId: randomUUID(),
|
|
agentId,
|
|
createdAt: new Date("2026-06-29T17:41:26.116Z"),
|
|
startedAt: new Date("2026-06-29T17:41:26.116Z"),
|
|
finishedAt: new Date("2026-06-29T17:46:33.794Z"),
|
|
logContent: "no comment id here",
|
|
},
|
|
{
|
|
runId: randomUUID(),
|
|
agentId,
|
|
createdAt: new Date("2026-06-29T17:40:09.531Z"),
|
|
startedAt: new Date("2026-06-29T17:40:09.531Z"),
|
|
finishedAt: new Date("2026-06-29T17:46:33.794Z"),
|
|
logContent: "also nothing",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.has(commentId)).toBe(false);
|
|
});
|
|
|
|
it("never reattributes a comment that already has a stored agent author", () => {
|
|
const commentId = randomUUID();
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: randomUUID(),
|
|
authorUserId: null,
|
|
createdByRunId: null,
|
|
createdAt: new Date("2026-05-11T18:55:40.090Z"),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
runId: randomUUID(),
|
|
agentId: randomUUID(),
|
|
createdAt: new Date("2026-05-11T18:51:56.246Z"),
|
|
startedAt: new Date("2026-05-11T18:51:56.257Z"),
|
|
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
|
|
logContent: "",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.has(commentId)).toBe(false);
|
|
});
|
|
});
|
|
|
|
async function ensureIssueRelationsTable(db: ReturnType<typeof createDb>) {
|
|
await db.execute(sql.raw(`
|
|
CREATE TABLE IF NOT EXISTS "issue_relations" (
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
"company_id" uuid NOT NULL,
|
|
"issue_id" uuid NOT NULL,
|
|
"related_issue_id" uuid NOT NULL,
|
|
"type" text NOT NULL,
|
|
"created_by_agent_id" uuid,
|
|
"created_by_user_id" text,
|
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
"updated_at" timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
`));
|
|
}
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
it("rejects stale monitor cancellation under the task lock and preserves saved policy settings", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Monitor cancellation", issuePrefix: "MCL", requireBoardApprovalForNewAgents: false });
|
|
const oldPolicy = {
|
|
mode: "normal" as const, commentRequired: true, stages: [],
|
|
monitor: { nextCheckAt: "2026-10-10T12:00:00.000Z", notes: "Check status", scheduledBy: "board" as const },
|
|
};
|
|
const savedPolicy = {
|
|
...oldPolicy,
|
|
stages: [{ type: "review" as const, approvalsNeeded: 1 as const, participants: [{ type: "user" as const, userId: "reviewer" }] }],
|
|
maxReviewRounds: 4,
|
|
authorizationPolicy: { assignmentPolicy: { mode: "protected" as const } },
|
|
};
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Monitored task", executionPolicy: oldPolicy });
|
|
await svc.update(issueId, { executionPolicy: savedPolicy });
|
|
const { monitor: _oldMonitor, ...staleCancellation } = oldPolicy;
|
|
await expect(svc.update(issueId, { executionPolicy: staleCancellation, expectedExecutionPolicy: oldPolicy }))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
expect((await svc.getById(issueId))?.executionPolicy).toEqual(savedPolicy);
|
|
const { monitor: _savedMonitor, ...cancellation } = savedPolicy;
|
|
await svc.update(issueId, { executionPolicy: cancellation, expectedExecutionPolicy: savedPolicy });
|
|
expect((await svc.getById(issueId))?.executionPolicy).toEqual(cancellation);
|
|
await expect(svc.update(issueId, { executionPolicy: null, expectedExecutionPolicy: null }))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
expect((await svc.getById(issueId))?.executionPolicy).toEqual(cancellation);
|
|
});
|
|
|
|
it("promotes prepared backlog tasks only at the observed status version", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Prepared task", status: "backlog" });
|
|
const original = (await svc.getById(issueId))!;
|
|
const results = await Promise.allSettled([
|
|
svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }),
|
|
svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }),
|
|
]);
|
|
expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1);
|
|
const rejected = results.find(result => result.status === "rejected");
|
|
expect(rejected?.status === "rejected" && rejected.reason)
|
|
.toMatchObject({ status: 409, details: { code: "issue_status_version_changed" } });
|
|
expect(await svc.getById(issueId)).toMatchObject({ status: "todo", statusVersion: original.statusVersion + 1 });
|
|
});
|
|
|
|
it("rejects guarded writes outside backlog promotion without changing the task", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Active task", status: "in_progress" });
|
|
const original = (await svc.getById(issueId))!;
|
|
await expect(svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
await expect(svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion, title: "Replaced" }))
|
|
.rejects.toMatchObject({ status: 422 });
|
|
expect(await svc.getById(issueId)).toMatchObject({ title: "Active task", status: "in_progress", statusVersion: original.statusVersion });
|
|
});
|
|
|
|
it.each(["status", "assignment"])("rejects a prepared promotion after a %s decision returns to its old value", async (change) => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const otherAgentId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
await seedCodexCoder(db, companyId, otherAgentId);
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Prepared task", status: "backlog", assigneeAgentId: agentId });
|
|
const original = (await svc.getById(issueId))!;
|
|
if (change === "status") {
|
|
await svc.update(issueId, { status: "cancelled" });
|
|
await svc.update(issueId, { status: "backlog" });
|
|
} else {
|
|
await svc.update(issueId, { assigneeAgentId: otherAgentId });
|
|
await svc.update(issueId, { assigneeAgentId: agentId });
|
|
}
|
|
const current = (await svc.getById(issueId))!;
|
|
expect(current.statusVersion).toBeGreaterThan(original.statusVersion);
|
|
await expect(svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }))
|
|
.rejects.toMatchObject({ status: 409, details: { code: "issue_status_version_changed" } });
|
|
expect((await svc.getById(issueId))!).toMatchObject({ status: "backlog", assigneeAgentId: agentId, statusVersion: current.statusVersion });
|
|
});
|
|
|
|
it.each(["release", "force release", "direct agent round trip", "direct human round trip"])(
|
|
"invalidates a prepared promotion after %s", async (change) => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const otherAgentId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
await seedCodexCoder(db, companyId, otherAgentId);
|
|
const issueId = randomUUID();
|
|
const humanAssignment = change === "direct human round trip";
|
|
await db.insert(issues).values({
|
|
id: issueId, companyId, title: "Prepared ownership", status: "backlog",
|
|
assigneeAgentId: humanAssignment ? null : agentId,
|
|
assigneeUserId: humanAssignment ? "original-owner" : null,
|
|
});
|
|
const original = (await svc.getById(issueId))!;
|
|
if (change === "release") {
|
|
await svc.release(issueId, agentId);
|
|
} else if (change === "force release") {
|
|
await svc.adminForceRelease(issueId, { clearAssignee: true });
|
|
} else if (humanAssignment) {
|
|
await db.update(issues).set({ assigneeUserId: "another-owner" }).where(eq(issues.id, issueId));
|
|
await db.update(issues).set({ assigneeUserId: "original-owner" }).where(eq(issues.id, issueId));
|
|
} else {
|
|
await db.update(issues).set({ assigneeAgentId: otherAgentId }).where(eq(issues.id, issueId));
|
|
await db.update(issues).set({ assigneeAgentId: agentId }).where(eq(issues.id, issueId));
|
|
}
|
|
const current = (await svc.getById(issueId))!;
|
|
expect(current.statusVersion).toBe(original.statusVersion + (change.startsWith("direct") ? 2 : 1));
|
|
await expect(svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }))
|
|
.rejects.toMatchObject({ status: 409, details: { code: "issue_status_version_changed" } });
|
|
expect(await svc.getById(issueId)).toMatchObject({
|
|
status: "backlog", assigneeAgentId: current.assigneeAgentId,
|
|
assigneeUserId: current.assigneeUserId, statusVersion: current.statusVersion,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps a prepared version when the stored assignee is unchanged", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Same owner", status: "backlog", assigneeAgentId: agentId });
|
|
const original = (await svc.getById(issueId))!;
|
|
await db.update(issues).set({ assigneeAgentId: agentId, assigneeUserId: null }).where(eq(issues.id, issueId));
|
|
expect((await svc.getById(issueId))?.statusVersion).toBe(original.statusVersion);
|
|
await expect(svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }))
|
|
.resolves.toMatchObject({ status: "todo", statusVersion: original.statusVersion + 1 });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueDocuments);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(issueReadStates);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(documents);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(goals);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedAssignableAgentCompany() {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
return companyId;
|
|
}
|
|
|
|
it("does not treat passive issue activity as touching it, but includes real user mutations", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const issueId = randomUUID();
|
|
const userId = "board-user";
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Issue viewed without participation",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
await svc.markRead(companyId, issueId, userId);
|
|
await db.insert(activityLog).values([
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.read_marked",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
},
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.file_resource_content_read",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
},
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.file_resource_download_denied",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
},
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.tree_control_previewed",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
},
|
|
]);
|
|
|
|
await expect(svc.list(companyId, { touchedByUserId: userId })).resolves.toEqual([]);
|
|
|
|
await db.insert(activityLog).values({
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.comment_cancelled",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
});
|
|
|
|
await expect(svc.list(companyId, { touchedByUserId: userId })).resolves.toEqual([
|
|
expect.objectContaining({ id: issueId }),
|
|
]);
|
|
});
|
|
|
|
it("ignores historical reader-attributed recovery cleanup without hiding explicit recovery decisions", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const userId = "board-user";
|
|
const old = new Date("2026-03-26T10:00:00Z");
|
|
const recent = new Date("2026-03-26T15:00:00Z");
|
|
const variants = [
|
|
{ id: randomUUID(), details: { source: "source_revalidation", trigger: "read_projection" }, counts: false },
|
|
{ id: randomUUID(), details: null, counts: true },
|
|
{ id: randomUUID(), details: { outcome: "restored" }, counts: true },
|
|
{ id: randomUUID(), details: { source: "source_revalidation", trigger: "issue_update" }, counts: true },
|
|
];
|
|
await db.insert(issues).values(variants.map(({ id }) => ({
|
|
id, companyId, title: "Recovery audit", status: "done", updatedAt: old,
|
|
})));
|
|
await db.insert(activityLog).values(variants.map(({ id, details }) => ({
|
|
companyId, actorType: "user", actorId: userId, action: "issue.recovery_action_resolved",
|
|
entityType: "issue", entityId: id, details, createdAt: recent,
|
|
})));
|
|
await db.insert(issueComments).values(variants.map(({ id }) => ({
|
|
companyId, issueId: id, authorUserId: "other-user", body: "An older update", createdAt: old,
|
|
})));
|
|
|
|
const expectedIds = variants.filter(v => v.counts).map(v => v.id).sort();
|
|
expect((await svc.list(companyId, { touchedByUserId: userId })).map(v => v.id).sort()).toEqual(expectedIds);
|
|
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(expectedIds.length);
|
|
const rows = await svc.list(companyId, {}) as IssueListRow[];
|
|
for (const variant of variants) {
|
|
expect(rows.find(v => v.id === variant.id)?.lastActivityAt).toEqual(variant.counts ? recent : old);
|
|
}
|
|
// Audit evidence is preserved, including the wrongly attributed old row.
|
|
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(variants.length);
|
|
});
|
|
|
|
function agentRow(companyId: string, input: {
|
|
id: string;
|
|
name: string;
|
|
status?: string;
|
|
reportsTo?: string | null;
|
|
}) {
|
|
return {
|
|
id: input.id,
|
|
companyId,
|
|
name: input.name,
|
|
role: "engineer",
|
|
status: input.status ?? "active",
|
|
reportsTo: input.reportsTo ?? null,
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
};
|
|
}
|
|
|
|
it("rejects direct terminated assignees with structured conflict details", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const terminatedAgentId = randomUUID();
|
|
await db.insert(agents).values(agentRow(companyId, {
|
|
id: terminatedAgentId,
|
|
name: "TerminatedCoder",
|
|
status: "terminated",
|
|
}));
|
|
|
|
await expect(svc.create(companyId, {
|
|
title: "Do not assign this",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: terminatedAgentId,
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "agent_not_assignable",
|
|
reason: "assignee_terminated",
|
|
assigneeAgentId: terminatedAgentId,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects invalid ancestor-chain assignees and preserves the existing assignment", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const activeAgentId = randomUUID();
|
|
const terminatedManagerId = randomUUID();
|
|
const blockedAgentId = randomUUID();
|
|
await db.insert(agents).values([
|
|
agentRow(companyId, { id: activeAgentId, name: "ActiveCoder" }),
|
|
agentRow(companyId, {
|
|
id: terminatedManagerId,
|
|
name: "TerminatedManager",
|
|
status: "terminated",
|
|
}),
|
|
agentRow(companyId, {
|
|
id: blockedAgentId,
|
|
name: "BlockedCoder",
|
|
reportsTo: terminatedManagerId,
|
|
}),
|
|
]);
|
|
const issue = await svc.create(companyId, {
|
|
title: "Keep current assignment",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: activeAgentId,
|
|
});
|
|
|
|
await expect(svc.update(issue.id, {
|
|
assigneeAgentId: blockedAgentId,
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "agent_not_assignable",
|
|
reason: "ancestor_terminated",
|
|
assigneeAgentId: blockedAgentId,
|
|
invalidAncestorAgentId: terminatedManagerId,
|
|
},
|
|
});
|
|
|
|
const persisted = await db
|
|
.select({ assigneeAgentId: issues.assigneeAgentId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issue.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(persisted?.assigneeAgentId).toBe(activeAgentId);
|
|
});
|
|
|
|
it("rejects checkout by a terminated agent before assigning the issue", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const terminatedAgentId = randomUUID();
|
|
await db.insert(agents).values(agentRow(companyId, {
|
|
id: terminatedAgentId,
|
|
name: "TerminatedCheckoutCoder",
|
|
status: "terminated",
|
|
}));
|
|
const issue = await svc.create(companyId, {
|
|
title: "Checkout must stay unassigned",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: null,
|
|
});
|
|
|
|
await expect(svc.checkout(issue.id, terminatedAgentId, ["todo"], randomUUID()))
|
|
.rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "agent_not_assignable",
|
|
reason: "assignee_terminated",
|
|
assigneeAgentId: terminatedAgentId,
|
|
},
|
|
});
|
|
|
|
const persisted = await db
|
|
.select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status })
|
|
.from(issues)
|
|
.where(eq(issues.id, issue.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(persisted).toMatchObject({
|
|
assigneeAgentId: null,
|
|
status: "todo",
|
|
});
|
|
});
|
|
|
|
it("expires a pending connection and its OAuth state when reassigned to a user without a status change", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const agentId = randomUUID();
|
|
const userId = randomUUID();
|
|
await db.insert(agents).values(agentRow(companyId, { id: agentId, name: "ConnectionRequester" }));
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member",
|
|
});
|
|
const issue = await svc.create(companyId, {
|
|
title: "Waiting for a connection", status: "in_review", priority: "medium", assigneeAgentId: agentId,
|
|
});
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId, name: "Connection app", type: "mcp_http",
|
|
}).returning();
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId, applicationId: application!.id, name: "Notion", uid: randomUUID(),
|
|
transport: "mcp_remote", authKind: "oauth",
|
|
}).returning();
|
|
const [interaction] = await db.insert(issueThreadInteractions).values({
|
|
companyId, issueId: issue.id, kind: "connection_intent", status: "pending",
|
|
createdByAgentId: agentId, addresseeUserId: "local-board",
|
|
payload: {
|
|
version: 1, serviceSlug: "notion", serviceName: "Notion",
|
|
requestingAgentId: agentId, requestingAgentName: "ConnectionRequester", phase: "authorizing",
|
|
},
|
|
}).returning();
|
|
// A second pending request on the same issue asks for an AI account. The
|
|
// expiry must tell the two apart: only the AI one blocks the run.
|
|
const [aiInteraction] = await db.insert(issueThreadInteractions).values({
|
|
companyId, issueId: issue.id, kind: "connection_intent", status: "pending",
|
|
createdByAgentId: agentId, addresseeUserId: "local-board",
|
|
payload: {
|
|
version: 1, purpose: "ai", serviceSlug: "connection:claude_local",
|
|
serviceName: "Claude", requestingAgentId: agentId,
|
|
requestingAgentName: "ConnectionRequester", phase: "authorizing",
|
|
},
|
|
}).returning();
|
|
const oauthState = randomUUID();
|
|
await db.insert(toolOauthStates).values({
|
|
state: oauthState, companyId, connectionId: connection!.id, issueId: issue.id,
|
|
interactionId: interaction!.id, codeVerifier: "test-verifier",
|
|
createdByActorType: "user", createdByActorId: "local-board",
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
|
|
const updated = await svc.update(issue.id, {
|
|
assigneeAgentId: null, assigneeUserId: userId, actorUserId: "local-board",
|
|
});
|
|
expect(updated).toMatchObject({ status: "in_review", assigneeAgentId: null, assigneeUserId: userId });
|
|
await expect(db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction!.id)))
|
|
.resolves.toEqual([expect.objectContaining({
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "expired",
|
|
reason: "The task was reassigned before the connection request was answered, so the Notion connection is still not connected. Reconnect it or choose an available connection.",
|
|
},
|
|
resolvedAt: expect.any(Date),
|
|
})]);
|
|
await expect(db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, aiInteraction!.id)))
|
|
.resolves.toEqual([expect.objectContaining({
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "expired",
|
|
reason: "The task was reassigned before the connection request was answered, so the AI account is still not connected. Reconnect it or choose an available connection.",
|
|
},
|
|
})]);
|
|
await expect(db.select().from(toolOauthStates).where(eq(toolOauthStates.state, oauthState))).resolves.toEqual([]);
|
|
});
|
|
|
|
it("expires pending thread interactions on any service-level terminal transition", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const issue = await svc.create(companyId, {
|
|
title: "Close me with a pending card",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
const interactionId = randomUUID();
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: interactionId,
|
|
companyId,
|
|
issueId: issue.id,
|
|
kind: "ask_user_questions",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Pick one",
|
|
selectionMode: "single",
|
|
options: [{ id: "a", label: "A" }],
|
|
}],
|
|
} as never,
|
|
});
|
|
|
|
// Direct service callers (tree control, recovery, pipelines, status cards)
|
|
// never pass through the HTTP routes, so the expiry must fire here.
|
|
const updated = await svc.update(issue.id, { status: "cancelled", actorUserId: "local-board" });
|
|
expect(updated?.status).toBe("cancelled");
|
|
|
|
const interaction = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(interaction).toMatchObject({
|
|
status: "expired",
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
expect(interaction?.result).toMatchObject({ version: 1, outcome: "issue_closed" });
|
|
expect(interaction?.resolvedAt).not.toBeNull();
|
|
|
|
const logged = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "issue.thread_interaction_expired"));
|
|
expect(logged).toHaveLength(1);
|
|
// details.source is dot-separated and trips the JWT-shaped redaction
|
|
// heuristic in sanitizeRecord, so assert the identifying fields instead.
|
|
expect(logged[0]?.details).toMatchObject({
|
|
interactionId,
|
|
interactionKind: "ask_user_questions",
|
|
interactionStatus: "expired",
|
|
});
|
|
});
|
|
|
|
it("expires superseded interactions when human comments are added through the service", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const issue = await svc.create(companyId, {
|
|
title: "Answer with a comment",
|
|
description: null,
|
|
status: "in_review",
|
|
priority: "medium",
|
|
});
|
|
const interactionId = randomUUID();
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: interactionId,
|
|
companyId,
|
|
issueId: issue.id,
|
|
kind: "ask_user_questions",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
supersedeOnUserComment: true,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Pick one",
|
|
selectionMode: "single",
|
|
options: [{ id: "a", label: "A" }],
|
|
}],
|
|
} as never,
|
|
});
|
|
|
|
const comment = await svc.addComment(issue.id, "Use option A", { userId: "local-board" });
|
|
|
|
const interaction = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(interaction).toMatchObject({
|
|
status: "expired",
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
expect(interaction?.result).toMatchObject({
|
|
expirationReason: "superseded_by_comment",
|
|
commentId: comment.id,
|
|
});
|
|
|
|
const logged = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "issue.thread_interaction_expired"));
|
|
expect(logged).toHaveLength(1);
|
|
expect(logged[0]?.details).toMatchObject({
|
|
interactionId,
|
|
interactionKind: "ask_user_questions",
|
|
interactionStatus: "expired",
|
|
});
|
|
});
|
|
|
|
it("keeps interactions pending when the board concierge adds a comment", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const issue = await svc.create(companyId, {
|
|
title: "Concierge reply",
|
|
description: null,
|
|
status: "in_review",
|
|
priority: "medium",
|
|
});
|
|
const interactionId = randomUUID();
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: interactionId,
|
|
companyId,
|
|
issueId: issue.id,
|
|
kind: "ask_user_questions",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
supersedeOnUserComment: true,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Pick one",
|
|
selectionMode: "single",
|
|
options: [{ id: "a", label: "A" }],
|
|
}],
|
|
} as never,
|
|
});
|
|
|
|
await svc.addComment(issue.id, "Automated concierge reply", {
|
|
userId: "board-concierge",
|
|
});
|
|
|
|
const interaction = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(interaction).toMatchObject({
|
|
status: "pending",
|
|
resolvedByUserId: null,
|
|
result: null,
|
|
});
|
|
|
|
const logged = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "issue.thread_interaction_expired"));
|
|
expect(logged).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects moving an existing terminated assignment into progress without clearing it", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const assigneeAgentId = randomUUID();
|
|
await db.insert(agents).values(agentRow(companyId, {
|
|
id: assigneeAgentId,
|
|
name: "SoonTerminatedCoder",
|
|
}));
|
|
const issue = await svc.create(companyId, {
|
|
title: "Do not restart after termination",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
});
|
|
await db.update(agents).set({ status: "terminated" }).where(eq(agents.id, assigneeAgentId));
|
|
|
|
await expect(svc.update(issue.id, {
|
|
status: "in_progress",
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "agent_not_assignable",
|
|
reason: "assignee_terminated",
|
|
assigneeAgentId,
|
|
},
|
|
});
|
|
|
|
const persisted = await db
|
|
.select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status })
|
|
.from(issues)
|
|
.where(eq(issues.id, issue.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(persisted).toMatchObject({
|
|
assigneeAgentId,
|
|
status: "todo",
|
|
});
|
|
});
|
|
|
|
it("resolves only structured same-company agent mentions", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const otherCompanyId = await seedAssignableAgentCompany();
|
|
const localAgentId = randomUUID();
|
|
const foreignAgentId = randomUUID();
|
|
|
|
await db.insert(agents).values([
|
|
agentRow(companyId, { id: localAgentId, name: "LocalAgent" }),
|
|
agentRow(otherCompanyId, { id: foreignAgentId, name: "ForeignAgent" }),
|
|
]);
|
|
|
|
const mentions = await svc.findMentionedAgents(
|
|
companyId,
|
|
[
|
|
`hello [@LocalAgent](${buildAgentMentionHref(localAgentId)})`,
|
|
`and [@ForeignAgent](${buildAgentMentionHref(foreignAgentId)})`,
|
|
].join(" "),
|
|
);
|
|
|
|
expect(mentions).toEqual([localAgentId]);
|
|
});
|
|
|
|
it("does not wake agents from raw @name text without a structured mention", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const localAgentId = randomUUID();
|
|
|
|
await db.insert(agents).values([
|
|
agentRow(companyId, { id: localAgentId, name: "LocalAgent" }),
|
|
]);
|
|
|
|
await expect(svc.findMentionedAgents(companyId, "@LocalAgent please inspect this"))
|
|
.resolves.toEqual([]);
|
|
});
|
|
|
|
it("returns issues an agent participated in across the supported signals", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const otherAgentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(agents).values([
|
|
{
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: otherAgentId,
|
|
companyId,
|
|
name: "OtherAgent",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
const assignedIssueId = randomUUID();
|
|
const createdIssueId = randomUUID();
|
|
const commentedIssueId = randomUUID();
|
|
const activityIssueId = randomUUID();
|
|
const excludedIssueId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: assignedIssueId,
|
|
companyId,
|
|
title: "Assigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
createdByAgentId: otherAgentId,
|
|
},
|
|
{
|
|
id: createdIssueId,
|
|
companyId,
|
|
title: "Created issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: agentId,
|
|
},
|
|
{
|
|
id: commentedIssueId,
|
|
companyId,
|
|
title: "Commented issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: otherAgentId,
|
|
},
|
|
{
|
|
id: activityIssueId,
|
|
companyId,
|
|
title: "Activity issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: otherAgentId,
|
|
},
|
|
{
|
|
id: excludedIssueId,
|
|
companyId,
|
|
title: "Excluded issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: otherAgentId,
|
|
assigneeAgentId: otherAgentId,
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId: commentedIssueId,
|
|
authorAgentId: agentId,
|
|
body: "Investigating this issue.",
|
|
});
|
|
|
|
await db.insert(activityLog).values({
|
|
companyId,
|
|
actorType: "agent",
|
|
actorId: agentId,
|
|
action: "issue.updated",
|
|
entityType: "issue",
|
|
entityId: activityIssueId,
|
|
agentId,
|
|
details: { changed: true },
|
|
});
|
|
|
|
const result = await svc.list(companyId, { participantAgentId: agentId });
|
|
const resultIds = new Set(result.map((issue) => issue.id));
|
|
|
|
expect(resultIds).toEqual(new Set([
|
|
assignedIssueId,
|
|
createdIssueId,
|
|
commentedIssueId,
|
|
activityIssueId,
|
|
]));
|
|
expect(resultIds.has(excludedIssueId)).toBe(false);
|
|
});
|
|
|
|
it("combines participation filtering with search", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
|
|
const matchedIssueId = randomUUID();
|
|
const otherIssueId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: matchedIssueId,
|
|
companyId,
|
|
title: "Invoice reconciliation",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: agentId,
|
|
},
|
|
{
|
|
id: otherIssueId,
|
|
companyId,
|
|
title: "Weekly planning",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: agentId,
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, {
|
|
participantAgentId: agentId,
|
|
q: "invoice",
|
|
});
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
|
});
|
|
|
|
it("filters and counts unassigned issues with assigneeAgentId='null'", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const assignedIssueId = randomUUID();
|
|
const unassignedIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "Assignee",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: assignedIssueId,
|
|
companyId,
|
|
title: "Assigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
id: unassignedIssueId,
|
|
companyId,
|
|
title: "Unassigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: null,
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, { assigneeAgentId: "null" });
|
|
expect(result.map((issue) => issue.id)).toEqual([unassignedIssueId]);
|
|
await expect(svc.count(companyId, { assigneeAgentId: "null" })).resolves.toBe(1);
|
|
});
|
|
|
|
it("filters and counts only issues assigned to the requested UUID", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const otherAgentId = randomUUID();
|
|
const assignedIssueId = randomUUID();
|
|
const otherIssueId = randomUUID();
|
|
const unassignedIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(agents).values([
|
|
{
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "Assignee",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: otherAgentId,
|
|
companyId,
|
|
name: "Other",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: assignedIssueId,
|
|
companyId,
|
|
title: "Assigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
id: otherIssueId,
|
|
companyId,
|
|
title: "Other issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: otherAgentId,
|
|
},
|
|
{
|
|
id: unassignedIssueId,
|
|
companyId,
|
|
title: "Unassigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: null,
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, { assigneeAgentId });
|
|
expect(result.map((issue) => issue.id)).toEqual([assignedIssueId]);
|
|
await expect(svc.count(companyId, { assigneeAgentId })).resolves.toBe(1);
|
|
});
|
|
|
|
it("rejects malformed assigneeAgentId filters in list and count", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
await db.insert(issues).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
title: "Any issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await expect(
|
|
svc.list(companyId, { assigneeAgentId: "not-a-uuid" }),
|
|
).rejects.toThrow(/assigneeAgentId/i);
|
|
await expect(
|
|
svc.count(companyId, { assigneeAgentId: "not-a-uuid" }),
|
|
).rejects.toThrow(/assigneeAgentId/i);
|
|
});
|
|
|
|
it("applies result limits to issue search", async () => {
|
|
const companyId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const exactIdentifierId = randomUUID();
|
|
const titleMatchId = randomUUID();
|
|
const descriptionMatchId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: exactIdentifierId,
|
|
companyId,
|
|
issueNumber: 42,
|
|
identifier: "PAP-42",
|
|
title: "Completely unrelated",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: titleMatchId,
|
|
companyId,
|
|
title: "Search ranking issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: descriptionMatchId,
|
|
companyId,
|
|
title: "Another item",
|
|
description: "Contains the search keyword",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, {
|
|
q: "search",
|
|
limit: 2,
|
|
});
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
|
|
});
|
|
|
|
it("filters issues by whether they have a plan document", async () => {
|
|
const companyId = randomUUID();
|
|
const withPlanId = randomUUID();
|
|
const withoutPlanId = randomUUID();
|
|
const otherDocumentId = randomUUID();
|
|
const planDocumentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: withPlanId,
|
|
companyId,
|
|
title: "Issue with plan",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: withoutPlanId,
|
|
companyId,
|
|
title: "Issue without plan",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
await db.insert(documents).values([
|
|
{
|
|
id: planDocumentId,
|
|
companyId,
|
|
title: null,
|
|
format: "markdown",
|
|
latestBody: "# Plan",
|
|
},
|
|
{
|
|
id: otherDocumentId,
|
|
companyId,
|
|
title: "Notes",
|
|
format: "markdown",
|
|
latestBody: "# Notes",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueDocuments).values([
|
|
{
|
|
companyId,
|
|
issueId: withPlanId,
|
|
documentId: planDocumentId,
|
|
key: "plan",
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: withoutPlanId,
|
|
documentId: otherDocumentId,
|
|
key: "notes",
|
|
},
|
|
]);
|
|
|
|
const withPlan = await svc.list(companyId, { hasPlanDocument: true });
|
|
const withoutPlan = await svc.list(companyId, { hasPlanDocument: false });
|
|
|
|
expect(withPlan.map((issue) => issue.id)).toEqual([withPlanId]);
|
|
expect(withoutPlan.map((issue) => issue.id)).toEqual([withoutPlanId]);
|
|
});
|
|
|
|
it("can page issues by most recently updated before priority", async () => {
|
|
const companyId = randomUUID();
|
|
const oldCriticalIssueId = randomUUID();
|
|
const recentMediumIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: oldCriticalIssueId,
|
|
companyId,
|
|
title: "Old critical issue",
|
|
status: "todo",
|
|
priority: "critical",
|
|
updatedAt: new Date("2026-05-01T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: recentMediumIssueId,
|
|
companyId,
|
|
title: "Recent medium issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
updatedAt: new Date("2026-05-17T21:12:29.993Z"),
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, {
|
|
limit: 1,
|
|
sortField: "updated",
|
|
sortDir: "desc",
|
|
});
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]);
|
|
});
|
|
|
|
it("ranks direct description matches ahead of comment-only matches", async () => {
|
|
const companyId = randomUUID();
|
|
const commentMatchId = randomUUID();
|
|
const descriptionMatchId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: commentMatchId,
|
|
companyId,
|
|
title: "Comment match",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: descriptionMatchId,
|
|
companyId,
|
|
title: "Description match",
|
|
description: "Contains pull/3303 in the description",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId: commentMatchId,
|
|
body: "Reference: https://github.com/paperclipai/paperclip/pull/3303",
|
|
});
|
|
|
|
const result = await svc.list(companyId, {
|
|
q: "pull/3303",
|
|
limit: 2,
|
|
includeRoutineExecutions: true,
|
|
});
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([descriptionMatchId, commentMatchId]);
|
|
});
|
|
|
|
it("filters issue lists to the full descendant tree for a root issue", async () => {
|
|
const companyId = randomUUID();
|
|
const rootId = randomUUID();
|
|
const childId = randomUUID();
|
|
const grandchildId = randomUUID();
|
|
const siblingId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: rootId,
|
|
companyId,
|
|
title: "Root",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: childId,
|
|
companyId,
|
|
parentId: rootId,
|
|
title: "Child",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: grandchildId,
|
|
companyId,
|
|
parentId: childId,
|
|
title: "Grandchild",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: siblingId,
|
|
companyId,
|
|
title: "Sibling",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, { descendantOf: rootId });
|
|
|
|
expect(new Set(result.map((issue) => issue.id))).toEqual(new Set([childId, grandchildId]));
|
|
});
|
|
|
|
it("combines descendant filtering with search", async () => {
|
|
const companyId = randomUUID();
|
|
const rootId = randomUUID();
|
|
const childId = randomUUID();
|
|
const grandchildId = randomUUID();
|
|
const outsideMatchId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: rootId,
|
|
companyId,
|
|
title: "Root",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: childId,
|
|
companyId,
|
|
parentId: rootId,
|
|
title: "Relevant parent",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: grandchildId,
|
|
companyId,
|
|
parentId: childId,
|
|
title: "Needle grandchild",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: outsideMatchId,
|
|
companyId,
|
|
title: "Needle outside",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, { descendantOf: rootId, q: "needle" });
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([grandchildId]);
|
|
});
|
|
|
|
it("accepts issue identifiers with alphanumeric prefixes through getById", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: "PC1A2",
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
issueNumber: 1064,
|
|
identifier: "PC1A2-1064",
|
|
title: "Feedback votes error",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: "user-1",
|
|
});
|
|
|
|
const issue = await svc.getById("pc1a2-1064");
|
|
|
|
expect(issue).toEqual(
|
|
expect.objectContaining({
|
|
id: issueId,
|
|
identifier: "PC1A2-1064",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns null instead of throwing for malformed non-uuid issue refs", async () => {
|
|
await expect(svc.getById("not-a-uuid")).resolves.toBeNull();
|
|
});
|
|
it("filters issues by execution workspace id", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const targetWorkspaceId = randomUUID();
|
|
const otherWorkspaceId = randomUUID();
|
|
const linkedIssueId = randomUUID();
|
|
const otherLinkedIssueId = randomUUID();
|
|
const unlinkedIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await seedWorkspaceProject(db, companyId, projectId);
|
|
|
|
await db.insert(executionWorkspaces).values([
|
|
{
|
|
id: targetWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
name: "Target workspace",
|
|
status: "active",
|
|
providerType: "local_fs",
|
|
},
|
|
{
|
|
id: otherWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
name: "Other workspace",
|
|
status: "active",
|
|
providerType: "local_fs",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: linkedIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Linked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceId: targetWorkspaceId,
|
|
},
|
|
{
|
|
id: otherLinkedIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Other linked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceId: otherWorkspaceId,
|
|
},
|
|
{
|
|
id: unlinkedIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Unlinked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, { executionWorkspaceId: targetWorkspaceId });
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]);
|
|
});
|
|
|
|
it("filters issues by generic workspace id across execution and project workspace links", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const executionLinkedIssueId = randomUUID();
|
|
const projectLinkedIssueId = randomUUID();
|
|
const otherIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await seedWorkspaceProject(db, companyId, projectId);
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Feature workspace",
|
|
sourceType: "local_path",
|
|
visibility: "default",
|
|
isPrimary: false,
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Execution workspace",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: executionLinkedIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Execution linked issue",
|
|
status: "done",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
},
|
|
{
|
|
id: projectLinkedIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Project linked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: otherIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Other issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const executionResult = await svc.list(companyId, { workspaceId: executionWorkspaceId });
|
|
const projectResult = await svc.list(companyId, { workspaceId: projectWorkspaceId });
|
|
|
|
expect(executionResult.map((issue) => issue.id)).toEqual([executionLinkedIssueId]);
|
|
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
|
|
});
|
|
|
|
it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const normalIssueId = randomUUID();
|
|
const pluginVisibleIssueId = randomUUID();
|
|
const operationIssueId = randomUUID();
|
|
const typedOperationIssueId = randomUUID();
|
|
const legacyContentMachineOperationIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Plugin Runner",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Plugin operations",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(issues).values([
|
|
{
|
|
id: normalIssueId,
|
|
companyId,
|
|
title: "Normal issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
},
|
|
{
|
|
id: pluginVisibleIssueId,
|
|
companyId,
|
|
title: "Plugin-visible issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
originKind: "plugin:paperclip.missions:feature",
|
|
},
|
|
{
|
|
id: operationIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Plugin operation issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
originKind: "plugin:paperclip.missions:operation",
|
|
originId: "mission-alpha:operation-1",
|
|
},
|
|
{
|
|
id: typedOperationIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Typed plugin operation issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
originKind: "plugin:paperclip.missions:operation:evaluation",
|
|
originId: "mission-alpha:operation-2",
|
|
},
|
|
{
|
|
id: legacyContentMachineOperationIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Legacy Content Machine operation issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
originKind: "plugin:paperclipai.content-machine:evaluation",
|
|
originId: "content-machine-operation-1",
|
|
},
|
|
]);
|
|
|
|
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
|
|
expect(defaultIssueIds).toContain(normalIssueId);
|
|
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
|
|
expect(defaultIssueIds).not.toContain(operationIssueId);
|
|
expect(defaultIssueIds).not.toContain(typedOperationIssueId);
|
|
expect(defaultIssueIds).not.toContain(legacyContentMachineOperationIssueId);
|
|
|
|
const inboxIssueIds = (await svc.list(companyId, {
|
|
assigneeAgentId: agentId,
|
|
status: "todo,in_progress,blocked",
|
|
includeRoutineExecutions: true,
|
|
})).map((issue) => issue.id);
|
|
expect(inboxIssueIds).toContain(normalIssueId);
|
|
expect(inboxIssueIds).not.toContain(operationIssueId);
|
|
expect(inboxIssueIds).not.toContain(typedOperationIssueId);
|
|
expect(inboxIssueIds).not.toContain(legacyContentMachineOperationIssueId);
|
|
|
|
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
|
|
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
|
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
|
|
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
|
await expect(svc.list(companyId, { originKindPrefix: "plugin:paperclip.missions:operation" }))
|
|
.resolves.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: operationIssueId }),
|
|
expect.objectContaining({ id: typedOperationIssueId }),
|
|
]));
|
|
|
|
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
|
|
expect(projectIssueIds).toContain(operationIssueId);
|
|
expect(projectIssueIds).toContain(typedOperationIssueId);
|
|
expect(projectIssueIds).toContain(legacyContentMachineOperationIssueId);
|
|
|
|
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
|
|
expect(advancedIssueIds).toContain(operationIssueId);
|
|
expect(advancedIssueIds).toContain(typedOperationIssueId);
|
|
expect(advancedIssueIds).toContain(legacyContentMachineOperationIssueId);
|
|
});
|
|
|
|
it("excludes plugin operation issues from unread inbox counts", async () => {
|
|
const companyId = randomUUID();
|
|
const userId = "board-user";
|
|
const otherUserId = "other-user";
|
|
const normalIssueId = randomUUID();
|
|
const operationIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await db.insert(issues).values([
|
|
{
|
|
id: normalIssueId,
|
|
companyId,
|
|
title: "Normal touched issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
},
|
|
{
|
|
id: operationIssueId,
|
|
companyId,
|
|
title: "Plugin operation touched issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
originKind: "plugin:paperclip.missions:operation",
|
|
},
|
|
]);
|
|
await db.insert(issueComments).values([
|
|
{
|
|
companyId,
|
|
issueId: normalIssueId,
|
|
authorUserId: otherUserId,
|
|
body: "Unread normal update.",
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: operationIssueId,
|
|
authorUserId: otherUserId,
|
|
body: "Unread operation update.",
|
|
},
|
|
]);
|
|
|
|
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
|
|
await expect(svc.countUnreadTouchedByUser(companyId, userId, ["todo", "in_progress"])).resolves.toBe(1);
|
|
});
|
|
|
|
it("accepts array-form status filters in list and count", async () => {
|
|
const companyId = randomUUID();
|
|
const todoIssueId = randomUUID();
|
|
const inProgressIssueId = randomUUID();
|
|
const doneIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await db.insert(issues).values([
|
|
{
|
|
id: todoIssueId,
|
|
companyId,
|
|
title: "Todo issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: inProgressIssueId,
|
|
companyId,
|
|
title: "In-progress issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: doneIssueId,
|
|
companyId,
|
|
title: "Done issue",
|
|
status: "done",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const resultIds = (await svc.list(companyId, { status: ["todo", "in_progress"] }))
|
|
.map((issue) => issue.id);
|
|
|
|
expect(resultIds).toEqual(expect.arrayContaining([todoIssueId, inProgressIssueId]));
|
|
expect(resultIds).not.toContain(doneIssueId);
|
|
await expect(svc.count(companyId, { status: ["todo", "in_progress"] })).resolves.toBe(2);
|
|
});
|
|
|
|
it("hides archived inbox issues until new external activity arrives", async () => {
|
|
const companyId = randomUUID();
|
|
const userId = "user-1";
|
|
const otherUserId = "user-2";
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const visibleIssueId = randomUUID();
|
|
const archivedIssueId = randomUUID();
|
|
const resurfacedIssueId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: visibleIssueId,
|
|
companyId,
|
|
title: "Visible issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: archivedIssueId,
|
|
companyId,
|
|
title: "Archived issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
},
|
|
{
|
|
id: resurfacedIssueId,
|
|
companyId,
|
|
title: "Resurfaced issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
await svc.archiveInbox(companyId, archivedIssueId, userId, new Date("2026-03-26T12:30:00.000Z"));
|
|
await svc.archiveInbox(companyId, resurfacedIssueId, userId, new Date("2026-03-26T13:00:00.000Z"));
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId: resurfacedIssueId,
|
|
authorUserId: otherUserId,
|
|
body: "This should bring the issue back into Mine.",
|
|
createdAt: new Date("2026-03-26T13:30:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:30:00.000Z"),
|
|
});
|
|
|
|
const archivedFiltered = await svc.list(companyId, {
|
|
touchedByUserId: userId,
|
|
inboxArchivedByUserId: userId,
|
|
});
|
|
|
|
expect(archivedFiltered.map((issue) => issue.id)).toEqual([
|
|
resurfacedIssueId,
|
|
visibleIssueId,
|
|
]);
|
|
|
|
await svc.unarchiveInbox(companyId, archivedIssueId, userId);
|
|
|
|
const afterUnarchive = await svc.list(companyId, {
|
|
touchedByUserId: userId,
|
|
inboxArchivedByUserId: userId,
|
|
});
|
|
|
|
expect(new Set(afterUnarchive.map((issue) => issue.id))).toEqual(new Set([
|
|
visibleIssueId,
|
|
archivedIssueId,
|
|
resurfacedIssueId,
|
|
]));
|
|
});
|
|
|
|
it("resurfaces archived issues only for user-attention events", async () => {
|
|
const companyId = randomUUID();
|
|
const userId = "user-1";
|
|
const otherUserId = "user-2";
|
|
const agentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Worker",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
});
|
|
|
|
const issueIds = {
|
|
updatedAt: randomUUID(),
|
|
agentComment: randomUUID(),
|
|
derivedAgentComment: randomUUID(),
|
|
systemComment: randomUUID(),
|
|
suggestTasks: randomUUID(),
|
|
askQuestions: randomUUID(),
|
|
requestConfirmation: randomUUID(),
|
|
inReview: randomUUID(),
|
|
blocked: randomUUID(),
|
|
done: randomUUID(),
|
|
humanComment: randomUUID(),
|
|
mention: randomUUID(),
|
|
unarchived: randomUUID(),
|
|
};
|
|
|
|
await db.insert(issues).values(Object.entries(issueIds).map(([title, id]) => ({
|
|
id,
|
|
companyId,
|
|
title,
|
|
status: "todo",
|
|
priority: "medium" as const,
|
|
createdByUserId: userId,
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
})));
|
|
|
|
const archivedAt = new Date("2026-03-26T12:00:00.000Z");
|
|
for (const issueId of Object.values(issueIds)) {
|
|
await svc.archiveInbox(companyId, issueId, userId, archivedAt);
|
|
}
|
|
|
|
const listVisibleIds = async () => new Set((await svc.list(companyId, {
|
|
touchedByUserId: userId,
|
|
inboxArchivedByUserId: userId,
|
|
})).map((issue) => issue.id));
|
|
|
|
await expect(listVisibleIds()).resolves.toEqual(new Set());
|
|
|
|
await db
|
|
.update(issues)
|
|
.set({ status: "in_progress", updatedAt: new Date("2026-03-26T13:00:00.000Z") })
|
|
.where(eq(issues.id, issueIds.updatedAt));
|
|
|
|
await db.insert(issueComments).values([
|
|
{
|
|
companyId,
|
|
issueId: issueIds.agentComment,
|
|
authorAgentId: agentId,
|
|
body: "Agent progress update",
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: issueIds.derivedAgentComment,
|
|
authorUserId: "local-board",
|
|
derivedAuthorAgentId: agentId,
|
|
derivedAuthorSource: "run_log_comment_post",
|
|
body: "Legacy agent-attributed progress update",
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: issueIds.systemComment,
|
|
body: "System lifecycle update",
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: issueIds.humanComment,
|
|
authorUserId: otherUserId,
|
|
body: "A human needs your attention",
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: issueIds.mention,
|
|
authorAgentId: agentId,
|
|
body: "Please review this, [Viewer](user://user-1)",
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueThreadInteractions).values({
|
|
companyId,
|
|
issueId: issueIds.suggestTasks,
|
|
kind: "suggest_tasks",
|
|
payload: { version: 1, tasks: [{ clientKey: "follow-up", title: "Follow up" }] },
|
|
createdByAgentId: agentId,
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
});
|
|
await db.insert(issueThreadInteractions).values({
|
|
companyId,
|
|
issueId: issueIds.askQuestions,
|
|
kind: "ask_user_questions",
|
|
payload: {
|
|
version: 1,
|
|
questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [] }],
|
|
},
|
|
createdByAgentId: agentId,
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
});
|
|
await db.insert(issueThreadInteractions).values({
|
|
companyId,
|
|
issueId: issueIds.requestConfirmation,
|
|
kind: "request_confirmation",
|
|
payload: { version: 1, prompt: "Proceed?" },
|
|
createdByAgentId: agentId,
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
});
|
|
|
|
await db.insert(activityLog).values([
|
|
[issueIds.inReview, "in_review"],
|
|
[issueIds.blocked, "blocked"],
|
|
[issueIds.done, "done"],
|
|
].map(([issueId, status]) => ({
|
|
companyId,
|
|
actorType: "agent",
|
|
actorId: agentId,
|
|
agentId,
|
|
action: "issue.updated",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
details: { status, _previous: { status: "in_progress" } },
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
})));
|
|
|
|
await svc.unarchiveInbox(companyId, issueIds.unarchived, userId);
|
|
|
|
const expectedVisible = new Set([
|
|
issueIds.suggestTasks,
|
|
issueIds.askQuestions,
|
|
issueIds.requestConfirmation,
|
|
issueIds.inReview,
|
|
issueIds.blocked,
|
|
issueIds.done,
|
|
issueIds.humanComment,
|
|
issueIds.mention,
|
|
issueIds.unarchived,
|
|
]);
|
|
await expect(listVisibleIds()).resolves.toEqual(expectedVisible);
|
|
|
|
await svc.archiveInbox(
|
|
companyId,
|
|
issueIds.humanComment,
|
|
userId,
|
|
new Date("2026-03-26T14:00:00.000Z"),
|
|
);
|
|
expectedVisible.delete(issueIds.humanComment);
|
|
|
|
await expect(listVisibleIds()).resolves.toEqual(expectedVisible);
|
|
});
|
|
|
|
it("sorts and exposes last activity from comments and non-local issue activity logs", async () => {
|
|
const companyId = randomUUID();
|
|
const olderIssueId = randomUUID();
|
|
const commentIssueId = randomUUID();
|
|
const activityIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: olderIssueId,
|
|
companyId,
|
|
title: "Older issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: commentIssueId,
|
|
companyId,
|
|
title: "Comment activity issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: activityIssueId,
|
|
companyId,
|
|
title: "Logged activity issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId: commentIssueId,
|
|
body: "New comment without touching issue.updatedAt",
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
});
|
|
|
|
await db.insert(activityLog).values([
|
|
{
|
|
companyId,
|
|
actorType: "system",
|
|
actorId: "system",
|
|
action: "issue.document_updated",
|
|
entityType: "issue",
|
|
entityId: activityIssueId,
|
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: "user-1",
|
|
action: "issue.read_marked",
|
|
entityType: "issue",
|
|
entityId: olderIssueId,
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, {}) as IssueListRow[];
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([
|
|
activityIssueId,
|
|
commentIssueId,
|
|
olderIssueId,
|
|
]);
|
|
expect(result.find((issue) => issue.id === activityIssueId)?.lastActivityAt?.toISOString()).toBe(
|
|
"2026-03-26T12:00:00.000Z",
|
|
);
|
|
expect(result.find((issue) => issue.id === commentIssueId)?.lastActivityAt?.toISOString()).toBe(
|
|
"2026-03-26T11:00:00.000Z",
|
|
);
|
|
expect(result.find((issue) => issue.id === olderIssueId)?.lastActivityAt?.toISOString()).toBe(
|
|
"2026-03-26T10:00:00.000Z",
|
|
);
|
|
});
|
|
|
|
it("paginates earlier comments in descending order from an anchor comment", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const firstCommentId = randomUUID();
|
|
const anchorCommentId = randomUUID();
|
|
const latestCommentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Paged comments issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(issueComments).values([
|
|
{
|
|
id: firstCommentId,
|
|
companyId,
|
|
issueId,
|
|
body: "First comment",
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: anchorCommentId,
|
|
companyId,
|
|
issueId,
|
|
body: "Anchor comment",
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
},
|
|
{
|
|
id: latestCommentId,
|
|
companyId,
|
|
issueId,
|
|
body: "Latest comment",
|
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
afterCommentId: anchorCommentId,
|
|
order: "desc",
|
|
limit: 50,
|
|
});
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([firstCommentId]);
|
|
});
|
|
|
|
it("paginates later comments in ascending order from an anchor comment", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const firstCommentId = randomUUID();
|
|
const anchorCommentId = randomUUID();
|
|
const latestCommentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Paged comments issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(issueComments).values([
|
|
{
|
|
id: firstCommentId,
|
|
companyId,
|
|
issueId,
|
|
body: "First comment",
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: anchorCommentId,
|
|
companyId,
|
|
issueId,
|
|
body: "Anchor comment",
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
},
|
|
{
|
|
id: latestCommentId,
|
|
companyId,
|
|
issueId,
|
|
body: "Latest comment",
|
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
afterCommentId: anchorCommentId,
|
|
order: "asc",
|
|
limit: 50,
|
|
});
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([latestCommentId]);
|
|
});
|
|
|
|
it("returns no comments for an anchor cursor that is not a UUID", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const commentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Malformed cursor issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
body: "Only comment",
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
});
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
afterCommentId: commentId.slice(0, 8),
|
|
order: "asc",
|
|
limit: 50,
|
|
});
|
|
|
|
expect(comments).toEqual([]);
|
|
});
|
|
|
|
it("lists user comments when derived run attribution scans a timestamp window", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const commentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Comments issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
contextSnapshot: { issueId },
|
|
createdAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
startedAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
finishedAt: new Date("2026-05-12T23:14:00.000Z"),
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Comment should be visible",
|
|
createdAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
});
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
order: "desc",
|
|
limit: 50,
|
|
});
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([commentId]);
|
|
expect(comments[0]?.body).toBe("Comment should be visible");
|
|
});
|
|
|
|
it("lists user comments when a candidate attribution run log is missing", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const commentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Comments issue with missing run log",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
contextSnapshot: { issueId },
|
|
createdAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
startedAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
finishedAt: new Date("2026-05-12T23:14:00.000Z"),
|
|
logStore: "local_file",
|
|
logRef: "missing/run-log.ndjson",
|
|
logBytes: 128,
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Comment should still be visible",
|
|
createdAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
});
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
order: "desc",
|
|
limit: 50,
|
|
});
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([commentId]);
|
|
expect(comments[0]?.body).toBe("Comment should still be visible");
|
|
expect(comments[0]?.metadata).toBeNull();
|
|
});
|
|
|
|
it("includes blockedBy summaries on list rows in one batched pass", async () => {
|
|
const companyId = randomUUID();
|
|
const blockerId = randomUUID();
|
|
const blockedId = randomUUID();
|
|
const unblockedId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: blockerId,
|
|
companyId,
|
|
title: "Blocker issue",
|
|
status: "todo",
|
|
priority: "high",
|
|
},
|
|
{
|
|
id: blockedId,
|
|
companyId,
|
|
title: "Blocked issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: unblockedId,
|
|
companyId,
|
|
title: "Unblocked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueRelations).values({
|
|
companyId,
|
|
issueId: blockerId,
|
|
relatedIssueId: blockedId,
|
|
type: "blocks",
|
|
});
|
|
|
|
const defaultResult = await svc.list(companyId) as IssueListRow[];
|
|
expect(defaultResult.find((issue) => issue.id === blockedId)?.blockedBy).toBeUndefined();
|
|
|
|
const result = await svc.list(companyId, { includeBlockedBy: true }) as IssueListRow[];
|
|
const byId = new Map(result.map((issue) => [issue.id, issue]));
|
|
|
|
expect(byId.get(blockedId)?.blockedBy).toEqual([
|
|
expect.objectContaining({
|
|
id: blockerId,
|
|
identifier: null,
|
|
title: "Blocker issue",
|
|
status: "todo",
|
|
priority: "high",
|
|
}),
|
|
]);
|
|
expect(byId.get(blockerId)?.blockedBy).toEqual([]);
|
|
expect(byId.get(unblockedId)?.blockedBy).toEqual([]);
|
|
});
|
|
|
|
it("trims large list payloads and preserves description preview boundaries", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const longDescription = "x".repeat(5_000);
|
|
const exactPreviewId = randomUUID();
|
|
const nullDescriptionId = randomUUID();
|
|
const multibyteBoundaryId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: issueId,
|
|
companyId,
|
|
title: "Large issue",
|
|
description: longDescription,
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) },
|
|
executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) },
|
|
executionWorkspaceSettings: { notes: "w".repeat(2_000) },
|
|
},
|
|
{
|
|
id: exactPreviewId, companyId, title: "Exact preview issue",
|
|
description: "x".repeat(1200), status: "todo", priority: "medium",
|
|
},
|
|
{
|
|
id: nullDescriptionId, companyId, title: "Null description issue",
|
|
description: null, status: "todo", priority: "medium",
|
|
},
|
|
{
|
|
id: multibyteBoundaryId, companyId, title: "Multibyte boundary issue",
|
|
description: `${"x".repeat(1199)}— still valid after truncation`,
|
|
status: "todo", priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const byId = new Map((await svc.list(companyId) as IssueListRow[]).map((issue) => [issue.id, issue]));
|
|
const result = byId.get(issueId);
|
|
|
|
expect(result).toBeTruthy();
|
|
expect(result?.description).toHaveLength(1200);
|
|
expect(result?.descriptionTruncated).toBe(true);
|
|
expect(result?.executionPolicy).toBeNull();
|
|
expect(result?.executionState).toBeNull();
|
|
expect(result?.executionWorkspaceSettings).toBeNull();
|
|
|
|
expect(byId.get(exactPreviewId)?.description).toHaveLength(1200);
|
|
expect(byId.get(exactPreviewId)?.descriptionTruncated).toBe(false);
|
|
expect(byId.get(nullDescriptionId)?.description).toBeNull();
|
|
expect(byId.get(nullDescriptionId)?.descriptionTruncated).toBe(false);
|
|
expect(byId.get(multibyteBoundaryId)?.description).toHaveLength(1200);
|
|
expect(byId.get(multibyteBoundaryId)?.description?.endsWith("—")).toBe(true);
|
|
expect(byId.get(multibyteBoundaryId)?.descriptionTruncated).toBe(true);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.findOpenAncestorCreatedByAgent", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-ancestor-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issues);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedChain() {
|
|
const companyId = randomUUID();
|
|
const delegatorId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Chain Co",
|
|
issuePrefix: `C${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: delegatorId,
|
|
companyId,
|
|
name: "Delegator",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
const rootId = randomUUID();
|
|
const midId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: rootId,
|
|
companyId,
|
|
title: "Root task",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
issueNumber: 1,
|
|
identifier: "CHAIN-1",
|
|
},
|
|
{
|
|
id: midId,
|
|
companyId,
|
|
title: "Delegated review",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
parentId: rootId,
|
|
createdByAgentId: delegatorId,
|
|
issueNumber: 2,
|
|
identifier: "CHAIN-2",
|
|
},
|
|
]);
|
|
return { companyId, delegatorId, rootId, midId };
|
|
}
|
|
|
|
it("finds an open ancestor created by the agent, at any depth", async () => {
|
|
const { delegatorId, midId } = await seedChain();
|
|
|
|
const direct = await svc.findOpenAncestorCreatedByAgent(midId, delegatorId);
|
|
expect(direct?.id).toBe(midId);
|
|
|
|
const other = await svc.findOpenAncestorCreatedByAgent(midId, randomUUID());
|
|
expect(other).toBeNull();
|
|
});
|
|
|
|
it("ignores closed ancestors created by the agent", async () => {
|
|
const { delegatorId, midId } = await seedChain();
|
|
await db.update(issues).set({ status: "done" }).where(eq(issues.id, midId));
|
|
|
|
expect(await svc.findOpenAncestorCreatedByAgent(midId, delegatorId)).toBeNull();
|
|
});
|
|
|
|
it("finds the ancestor through an arbitrarily deep chain", async () => {
|
|
const { companyId, delegatorId, midId } = await seedChain();
|
|
// Extend the chain 60 levels below the delegator-created ancestor so the
|
|
// match sits far above the new child's parent.
|
|
let parentId = midId;
|
|
const descendants: (typeof issues.$inferInsert)[] = [];
|
|
for (let index = 0; index < 60; index += 1) {
|
|
const childId = randomUUID();
|
|
descendants.push({
|
|
id: childId,
|
|
companyId,
|
|
title: `Deep child ${index}`,
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
parentId,
|
|
issueNumber: index + 10,
|
|
identifier: `CHAIN-${index + 10}`,
|
|
});
|
|
parentId = childId;
|
|
}
|
|
|
|
await db.insert(issues).values(descendants);
|
|
|
|
const found = await svc.findOpenAncestorCreatedByAgent(parentId, delegatorId);
|
|
expect(found?.id).toBe(midId);
|
|
});
|
|
|
|
it("terminates on a corrupted parent-graph cycle", async () => {
|
|
const { delegatorId, rootId, midId } = await seedChain();
|
|
// Corrupt the graph: root's parent points back at mid.
|
|
await db.update(issues).set({ parentId: midId }).where(eq(issues.id, rootId));
|
|
|
|
const found = await svc.findOpenAncestorCreatedByAgent(midId, delegatorId);
|
|
expect(found?.id).toBe(midId);
|
|
expect(await svc.findOpenAncestorCreatedByAgent(midId, randomUUID())).toBeNull();
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(goals);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(environments);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await seedWorkspaceProject(db, companyId, projectId);
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
sharedWorkspaceKey: "workspace-key",
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
providerRef: `/tmp/${executionWorkspaceId}`,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceRuntime: { profile: "agent" },
|
|
},
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
projectId,
|
|
title: "Child issue",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
mode: "isolated_workspace",
|
|
workspaceRuntime: { profile: "agent" },
|
|
});
|
|
});
|
|
|
|
it("inherits responsible user for agent-created child issues", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const responsibleUserId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Coder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const parent = await svc.create(companyId, {
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
createdByUserId: responsibleUserId,
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
responsibleUserId,
|
|
contextSnapshot: { issueId: parent.id },
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parent.id,
|
|
title: "Agent-created child",
|
|
createdByAgentId: agentId,
|
|
actorRunId: runId,
|
|
});
|
|
|
|
expect(parent.responsibleUserId).toBe(responsibleUserId);
|
|
expect(child.responsibleUserId).toBe(responsibleUserId);
|
|
});
|
|
|
|
it("creates children under a private issue in the same private subtree", async () => {
|
|
const companyId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Private subtree company",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
});
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
title: "Private root",
|
|
visibility: "private",
|
|
privacyRootIssueId: parentIssueId,
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
title: "Inherited private child",
|
|
visibility: "open",
|
|
});
|
|
|
|
expect(child.visibility).toBe("private");
|
|
expect(child.privacyRootIssueId).toBe(parentIssueId);
|
|
});
|
|
|
|
it("creates one personal private project and parks private tasks there", async () => {
|
|
const companyId = randomUUID();
|
|
const responsibleUserId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Personal private tasks company",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
});
|
|
|
|
const [first, second] = await Promise.all([
|
|
svc.create(companyId, {
|
|
title: "First personal private task",
|
|
visibility: "private",
|
|
createdByUserId: responsibleUserId,
|
|
}),
|
|
svc.create(companyId, {
|
|
title: "Second personal private task",
|
|
visibility: "private",
|
|
createdByUserId: responsibleUserId,
|
|
}),
|
|
]);
|
|
|
|
const personalProjects = await db.select().from(projects)
|
|
.where(eq(projects.personalOwnerUserId, responsibleUserId));
|
|
expect(personalProjects).toHaveLength(1);
|
|
expect(personalProjects[0]).toMatchObject({
|
|
companyId,
|
|
name: "My private tasks",
|
|
visibility: "private",
|
|
});
|
|
expect(first.projectId).toBe(personalProjects[0]!.id);
|
|
expect(second.projectId).toBe(personalProjects[0]!.id);
|
|
expect(first.privacyRootIssueId).toBe(first.id);
|
|
expect(second.privacyRootIssueId).toBe(second.id);
|
|
await expect(db.select().from(projectAccessMembers)
|
|
.where(eq(projectAccessMembers.projectId, personalProjects[0]!.id)))
|
|
.resolves.toEqual([
|
|
expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }),
|
|
]);
|
|
});
|
|
|
|
it("keeps a responsible human and grants an agent access to its explicitly private task", async () => {
|
|
const companyId = randomUUID();
|
|
const responsibleUserId = randomUUID();
|
|
const creatorAgentId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Agent private task company",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: creatorAgentId,
|
|
companyId,
|
|
name: "Private task creator",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const issue = await svc.create(companyId, {
|
|
title: "Agent email scratch task",
|
|
visibility: "private",
|
|
createdByAgentId: creatorAgentId,
|
|
actorResponsibleUserId: responsibleUserId,
|
|
});
|
|
|
|
expect(issue.responsibleUserId).toBe(responsibleUserId);
|
|
const personalProject = await db.select().from(projects)
|
|
.where(eq(projects.id, issue.projectId!))
|
|
.then((rows) => rows[0]!);
|
|
expect(personalProject.personalOwnerUserId).toBe(responsibleUserId);
|
|
await expect(db.select().from(projectAccessMembers)
|
|
.where(eq(projectAccessMembers.projectId, personalProject.id)))
|
|
.resolves.toEqual([
|
|
expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }),
|
|
]);
|
|
await expect(db.select().from(issueAccessGrants)
|
|
.where(eq(issueAccessGrants.issueId, issue.id)))
|
|
.resolves.toEqual([
|
|
expect.objectContaining({
|
|
subjectType: "agent",
|
|
subjectId: creatorAgentId,
|
|
source: "explicit",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("only honors explicit responsibleUserId for trusted issue create callers", async () => {
|
|
const companyId = randomUUID();
|
|
const creatorUserId = randomUUID();
|
|
const requestedResponsibleUserId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const untrusted = await svc.create(companyId, {
|
|
title: "Untrusted explicit responsible user",
|
|
createdByUserId: creatorUserId,
|
|
responsibleUserId: requestedResponsibleUserId,
|
|
});
|
|
const trusted = await svc.create(companyId, {
|
|
title: "Trusted explicit responsible user",
|
|
createdByUserId: creatorUserId,
|
|
responsibleUserId: requestedResponsibleUserId,
|
|
trustExplicitResponsibleUserId: true,
|
|
});
|
|
|
|
expect(untrusted.responsibleUserId).toBe(creatorUserId);
|
|
expect(trusted.responsibleUserId).toBe(requestedResponsibleUserId);
|
|
});
|
|
|
|
it("derives responsible user from authenticated actor context without trusting issue body", async () => {
|
|
const companyId = randomUUID();
|
|
const actorResponsibleUserId = randomUUID();
|
|
const requestedResponsibleUserId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const issue = await svc.create(companyId, {
|
|
title: "Actor-context responsible user",
|
|
responsibleUserId: requestedResponsibleUserId,
|
|
actorResponsibleUserId,
|
|
});
|
|
|
|
expect(issue.responsibleUserId).toBe(actorResponsibleUserId);
|
|
});
|
|
|
|
it("does not stamp the assignee default environment onto new issues", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const assigneeEnvironmentId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(environments).values([
|
|
{
|
|
id: assigneeEnvironmentId,
|
|
name: "QA E2B",
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: { provider: "e2b" },
|
|
},
|
|
]);
|
|
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "QA E2B Codex",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: assigneeEnvironmentId,
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
allowIssueOverride: true,
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
},
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
const issue = await svc.create(companyId, {
|
|
projectId,
|
|
assigneeAgentId,
|
|
title: "Environment matrix: e2b / codex_local",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
expect(issue.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
});
|
|
|
|
it("ignores legacy project environment selection when creating new issues", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const projectEnvironmentId = randomUUID();
|
|
const assigneeEnvironmentId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(environments).values([
|
|
{
|
|
id: projectEnvironmentId,
|
|
name: "QA SSH",
|
|
driver: "ssh",
|
|
status: "active",
|
|
config: {},
|
|
},
|
|
{
|
|
id: assigneeEnvironmentId,
|
|
name: "QA E2B",
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: { provider: "e2b" },
|
|
},
|
|
]);
|
|
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "QA E2B Codex",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: assigneeEnvironmentId,
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
allowIssueOverride: true,
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
environmentId: projectEnvironmentId,
|
|
},
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
const issue = await svc.create(companyId, {
|
|
projectId,
|
|
assigneeAgentId,
|
|
title: "Environment matrix: e2b / codex_local",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" });
|
|
});
|
|
|
|
it("does not rewrite execution workspace settings on reassignment", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const firstEnvironmentId = randomUUID();
|
|
const secondEnvironmentId = randomUUID();
|
|
const firstAgentId = randomUUID();
|
|
const secondAgentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(environments).values([
|
|
{
|
|
id: firstEnvironmentId,
|
|
name: "QA SSH",
|
|
driver: "ssh",
|
|
status: "active",
|
|
config: {},
|
|
},
|
|
{
|
|
id: secondEnvironmentId,
|
|
name: "QA E2B",
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: { provider: "e2b" },
|
|
},
|
|
]);
|
|
|
|
await db.insert(agents).values([
|
|
{
|
|
id: firstAgentId,
|
|
companyId,
|
|
name: "QA SSH Codex",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: firstEnvironmentId,
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: secondAgentId,
|
|
companyId,
|
|
name: "QA E2B Codex",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: secondEnvironmentId,
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
allowIssueOverride: true,
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
},
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
const created = await svc.create(companyId, {
|
|
projectId,
|
|
assigneeAgentId: firstAgentId,
|
|
title: "Environment matrix: ssh / codex_local",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
expect(created.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
|
|
const reassigned = await svc.update(created.id, {
|
|
assigneeAgentId: secondAgentId,
|
|
});
|
|
|
|
expect(reassigned).not.toBeNull();
|
|
expect(reassigned!.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
});
|
|
|
|
it("strips legacy environmentId values from execution workspace settings updates", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const firstEnvironmentId = randomUUID();
|
|
const secondEnvironmentId = randomUUID();
|
|
const operatorEnvironmentId = randomUUID();
|
|
const firstAgentId = randomUUID();
|
|
const secondAgentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(environments).values([
|
|
{ id: firstEnvironmentId, name: "Env 1", driver: "ssh", status: "active", config: {} },
|
|
{ id: secondEnvironmentId, name: "Env 2", driver: "sandbox", status: "active", config: { provider: "e2b" } },
|
|
{ id: operatorEnvironmentId, name: "Operator pick", driver: "ssh", status: "active", config: {} },
|
|
]);
|
|
|
|
await db.insert(agents).values([
|
|
{
|
|
id: firstAgentId, companyId, name: "First agent", role: "engineer", status: "active",
|
|
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
|
defaultEnvironmentId: firstEnvironmentId, permissions: {},
|
|
},
|
|
{
|
|
id: secondAgentId, companyId, name: "Second agent", role: "engineer", status: "active",
|
|
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
|
defaultEnvironmentId: secondEnvironmentId, permissions: {},
|
|
},
|
|
]);
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId, companyId, name: "Workspace project", status: "in_progress",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
allowIssueOverride: true,
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
},
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId, companyId, projectId, name: "Primary workspace", isPrimary: true,
|
|
});
|
|
|
|
const created = await svc.create(companyId, {
|
|
projectId,
|
|
assigneeAgentId: firstAgentId,
|
|
title: "Operator overrides env then reassigns",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
const overridden = await svc.update(created.id, {
|
|
executionWorkspaceSettings: {
|
|
mode: "shared_workspace",
|
|
environmentId: operatorEnvironmentId,
|
|
},
|
|
});
|
|
expect(overridden!.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
|
|
const reassigned = await svc.update(created.id, {
|
|
assigneeAgentId: secondAgentId,
|
|
});
|
|
expect(reassigned!.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
});
|
|
|
|
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const parentProjectWorkspaceId = randomUUID();
|
|
const parentExecutionWorkspaceId = randomUUID();
|
|
const explicitProjectWorkspaceId = randomUUID();
|
|
const explicitExecutionWorkspaceId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await seedWorkspaceProject(db, companyId, projectId);
|
|
|
|
await db.insert(projectWorkspaces).values([
|
|
{
|
|
id: parentProjectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Parent workspace",
|
|
},
|
|
{
|
|
id: explicitProjectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Explicit workspace",
|
|
},
|
|
]);
|
|
|
|
await db.insert(executionWorkspaces).values([
|
|
{
|
|
id: parentExecutionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Parent worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
},
|
|
{
|
|
id: explicitExecutionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: explicitProjectWorkspaceId,
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
name: "Explicit shared workspace",
|
|
status: "active",
|
|
providerType: "local_fs",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId: parentExecutionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
},
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
projectId,
|
|
title: "Child issue",
|
|
projectWorkspaceId: explicitProjectWorkspaceId,
|
|
executionWorkspaceId: explicitExecutionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "shared_workspace",
|
|
},
|
|
});
|
|
|
|
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
});
|
|
|
|
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const sourceIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await seedWorkspaceProject(db, companyId, projectId);
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "operator_branch",
|
|
strategyType: "git_worktree",
|
|
name: "Operator branch",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: sourceIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Source issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "operator_branch",
|
|
},
|
|
});
|
|
|
|
const followUp = await svc.create(companyId, {
|
|
projectId,
|
|
title: "Follow-up issue",
|
|
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
|
});
|
|
|
|
expect(followUp.parentId).toBeNull();
|
|
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(followUp.executionWorkspaceSettings).toEqual({
|
|
mode: "operator_branch",
|
|
});
|
|
});
|
|
|
|
it("createChild applies parent defaults, acceptance criteria, workspace inheritance, and optional parent blocker chaining", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Ship child helpers",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
goalId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
providerRef: `/tmp/${executionWorkspaceId}`,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
goalId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
requestDepth: 1,
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
},
|
|
});
|
|
|
|
const { issue: child, parentBlockerAdded } = await svc.createChild(parentIssueId, {
|
|
title: "Child helper",
|
|
status: "todo",
|
|
description: "Implement the helper.",
|
|
acceptanceCriteria: ["Uses the parent issue as parentId", "Reuses the parent execution workspace"],
|
|
blockParentUntilDone: true,
|
|
});
|
|
|
|
expect(parentBlockerAdded).toBe(true);
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectId).toBe(projectId);
|
|
expect(child.goalId).toBe(goalId);
|
|
expect(child.requestDepth).toBe(2);
|
|
expect(child.description).toContain("## Acceptance Criteria");
|
|
expect(child.description).toContain("- Uses the parent issue as parentId");
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
|
|
const parentRelations = await svc.getRelationSummaries(parentIssueId);
|
|
expect(parentRelations.blockedBy).toEqual([
|
|
expect.objectContaining({
|
|
id: child.id,
|
|
title: "Child helper",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("createChild preserves strategy-only workspace intent without realizing the parent workspace", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const environmentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await seedWorkspaceProject(db, companyId, projectId);
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Accepted plan parent",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
environmentId,
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/master",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
},
|
|
},
|
|
});
|
|
|
|
const { issue: child } = await svc.createChild(parentIssueId, {
|
|
title: "Accepted plan child",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceInheritanceMode: "strategy_only",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectId).toBe(projectId);
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBeNull();
|
|
expect(child.executionWorkspacePreference).toBeNull();
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
mode: "isolated_workspace",
|
|
environmentId,
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/master",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("createChild targeting another project does not forward the parent project workspace", async () => {
|
|
const companyId = randomUUID();
|
|
const parentProjectId = randomUUID();
|
|
const targetProjectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const parentProjectWorkspaceId = randomUUID();
|
|
const targetProjectWorkspaceId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(projects).values([
|
|
{ id: parentProjectId, companyId, name: "Paperclip App", status: "in_progress" },
|
|
{ id: targetProjectId, companyId, name: "Paperclip ID", status: "in_progress" },
|
|
]);
|
|
|
|
await db.insert(projectWorkspaces).values([
|
|
{
|
|
id: parentProjectWorkspaceId,
|
|
companyId,
|
|
projectId: parentProjectId,
|
|
name: "paperclip",
|
|
isPrimary: true,
|
|
},
|
|
{
|
|
id: targetProjectWorkspaceId,
|
|
companyId,
|
|
projectId: targetProjectId,
|
|
name: "paperclip-id",
|
|
isPrimary: true,
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId: parentProjectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
title: "Google Workspace MCP",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "git_worktree", baseRef: "origin/master" },
|
|
},
|
|
});
|
|
|
|
const { issue: child } = await svc.createChild(parentIssueId, {
|
|
title: "Implement the Paperclip ID connect broker",
|
|
status: "todo",
|
|
priority: "medium",
|
|
projectId: targetProjectId,
|
|
executionWorkspaceInheritanceMode: "strategy_only",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectId).toBe(targetProjectId);
|
|
expect(child.projectWorkspaceId).toBe(targetProjectWorkspaceId);
|
|
});
|
|
|
|
it("clamps helper-created child requestDepth to the safe maximum", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Ship child helpers",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
goalId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
goalId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
requestDepth: MAX_ISSUE_REQUEST_DEPTH,
|
|
});
|
|
|
|
const { issue: child } = await svc.createChild(parentIssueId, {
|
|
title: "Child helper",
|
|
status: "todo",
|
|
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 100,
|
|
});
|
|
|
|
expect(child.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
|
});
|
|
|
|
it("counts only non-terminal children toward the helper child limit", async () => {
|
|
const companyId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
title: "Recurring coordinator",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
});
|
|
|
|
const terminalChildren = Array.from(
|
|
{ length: MAX_CHILD_ISSUES_CREATED_BY_HELPER },
|
|
(_, index) => ({
|
|
id: randomUUID(),
|
|
companyId,
|
|
parentId: parentIssueId,
|
|
title: `Terminal child ${index + 1}`,
|
|
status: index % 2 === 0 ? "done" as const : "cancelled" as const,
|
|
priority: "medium" as const,
|
|
}),
|
|
);
|
|
const activeChildren = Array.from(
|
|
{ length: MAX_CHILD_ISSUES_CREATED_BY_HELPER - 1 },
|
|
(_, index) => ({
|
|
id: randomUUID(),
|
|
companyId,
|
|
parentId: parentIssueId,
|
|
title: `Active child ${index + 1}`,
|
|
status: "todo" as const,
|
|
priority: "medium" as const,
|
|
}),
|
|
);
|
|
await db.insert(issues).values([...terminalChildren, ...activeChildren]);
|
|
|
|
const { issue: finalActiveChild } = await svc.createChild(parentIssueId, {
|
|
title: "Final active child",
|
|
status: "todo",
|
|
});
|
|
|
|
expect(finalActiveChild.parentId).toBe(parentIssueId);
|
|
await expect(svc.createChild(parentIssueId, {
|
|
title: "Over active limit",
|
|
status: "todo",
|
|
})).rejects.toThrow(
|
|
`Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} active child issues for this helper`,
|
|
);
|
|
});
|
|
});
|
|
|
|
const dependencyTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ISSUE_DEPENDENCIES_TEST_DATABASE_URL");
|
|
(dependencyTestDatabaseUrl ? describe : describeEmbeddedPostgres)("issueService blockers and dependency wake readiness", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
if (!dependencyTestDatabaseUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-blockers-");
|
|
db = createDb(dependencyTestDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 6 });
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(workspaceOperations);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 5 });
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedSharedWorkspaceDependency() {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const blockerId = randomUUID();
|
|
const dependentId = randomUUID();
|
|
const foreignIssueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "QA",
|
|
role: "qa",
|
|
status: "active",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Shared workspace project",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Shared workspace",
|
|
sourceType: "local_path",
|
|
visibility: "default",
|
|
isPrimary: true,
|
|
});
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Shared exec workspace",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
await db.insert(issues).values([
|
|
{
|
|
id: blockerId,
|
|
companyId,
|
|
projectId,
|
|
identifier: "PAP-15043",
|
|
title: "Predecessor",
|
|
status: "done",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
},
|
|
{
|
|
id: dependentId,
|
|
companyId,
|
|
projectId,
|
|
identifier: "PAP-15046",
|
|
title: "Dependent",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
id: foreignIssueId,
|
|
companyId,
|
|
projectId,
|
|
identifier: "PAP-15125",
|
|
title: "Foreign in-flight issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
},
|
|
]);
|
|
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
|
|
|
return {
|
|
companyId,
|
|
assigneeAgentId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
executionWorkspaceId,
|
|
blockerId,
|
|
dependentId,
|
|
foreignIssueId,
|
|
};
|
|
}
|
|
|
|
it("returns authoritative update receipts for row fields and blocker relations", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const blockerId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
await db.insert(issues).values([
|
|
{
|
|
id: issueId,
|
|
companyId,
|
|
title: "Receipt issue",
|
|
description: "old description",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: blockerId,
|
|
companyId,
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "high",
|
|
},
|
|
]);
|
|
|
|
const fieldUpdate = await svc.update(issueId, {
|
|
title: "Receipt issue",
|
|
priority: "high",
|
|
description: "new description",
|
|
});
|
|
expect(fieldUpdate?.changes).toEqual({
|
|
priority: { from: "medium", to: "high" },
|
|
description: { from: "old description", to: "new description", updated: true },
|
|
});
|
|
|
|
const blockersSet = await svc.update(issueId, { blockedByIssueIds: [blockerId, blockerId] });
|
|
expect(blockersSet?.blockedByIssueIds).toEqual([blockerId]);
|
|
expect(blockersSet?.changes.blockedByIssueIds).toEqual({ from: [], to: [blockerId] });
|
|
|
|
const blockersCleared = await svc.update(issueId, { blockedByIssueIds: [] });
|
|
expect(blockersCleared?.blockedByIssueIds).toEqual([]);
|
|
expect(blockersCleared?.changes.blockedByIssueIds).toEqual({ from: [blockerId], to: [] });
|
|
|
|
await db.update(issues).set({
|
|
title: "Concurrent receipt issue",
|
|
priority: "medium",
|
|
}).where(eq(issues.id, issueId));
|
|
const [titleUpdate, priorityUpdate] = await Promise.all([
|
|
svc.update(issueId, { title: "Concurrent title" }),
|
|
svc.update(issueId, { priority: "high" }),
|
|
]);
|
|
expect(titleUpdate?.changes).toEqual({
|
|
title: { from: "Concurrent receipt issue", to: "Concurrent title" },
|
|
});
|
|
expect(priorityUpdate?.changes).toEqual({
|
|
priority: { from: "medium", to: "high" },
|
|
});
|
|
});
|
|
|
|
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const blockerId = randomUUID();
|
|
const blockedId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: blockerId,
|
|
companyId,
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "high",
|
|
},
|
|
{
|
|
id: blockedId,
|
|
companyId,
|
|
title: "Blocked issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
await svc.update(blockedId, {
|
|
blockedByIssueIds: [blockerId],
|
|
});
|
|
|
|
const blockerRelations = await svc.getRelationSummaries(blockerId);
|
|
const blockedRelations = await svc.getRelationSummaries(blockedId);
|
|
|
|
expect(blockerRelations.blocks.map((relation) => relation.id)).toEqual([blockedId]);
|
|
expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
|
|
});
|
|
|
|
it("returns blocked-by summaries on newly created issues", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const blockerId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: blockerId,
|
|
companyId,
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "high",
|
|
});
|
|
|
|
const created = await svc.create(companyId, {
|
|
title: "Blocked issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
blockedByIssueIds: [blockerId],
|
|
});
|
|
|
|
expect(created.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
|
|
expect(created.blockedBy[0]).toEqual(expect.objectContaining({
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "high",
|
|
}));
|
|
expect(created.blocks).toEqual([]);
|
|
});
|
|
|
|
it("returns blocked-by summaries on newly created child issues", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const parentId = randomUUID();
|
|
const blockerId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: parentId,
|
|
companyId,
|
|
title: "Parent",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: blockerId,
|
|
companyId,
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "high",
|
|
},
|
|
]);
|
|
|
|
const { issue: child } = await svc.createChild(parentId, {
|
|
title: "Blocked child issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
blockedByIssueIds: [blockerId],
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentId);
|
|
expect(child.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
|
|
expect(child.blocks).toEqual([]);
|
|
});
|
|
|
|
it("returns blocks summaries when child creation blocks the parent", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const parentId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: parentId,
|
|
companyId,
|
|
title: "Parent",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
const { issue: child } = await svc.createChild(parentId, {
|
|
title: "Parent-blocking child",
|
|
status: "todo",
|
|
priority: "medium",
|
|
blockParentUntilDone: true,
|
|
});
|
|
|
|
expect(child.blocks.map((relation) => relation.id)).toEqual([parentId]);
|
|
expect(child.blocks[0]).toEqual(expect.objectContaining({
|
|
title: "Parent",
|
|
status: "todo",
|
|
priority: "medium",
|
|
}));
|
|
expect(child.blockedBy).toEqual([]);
|
|
});
|
|
|
|
it("adds terminal blockers to immediate blocked-by summaries", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const issueA = randomUUID();
|
|
const issueB = randomUUID();
|
|
const issueC = randomUUID();
|
|
const issueD = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: issueA, companyId, identifier: "PAP-1", title: "Issue A", status: "blocked", priority: "medium" },
|
|
{ id: issueB, companyId, identifier: "PAP-2", title: "Issue B", status: "blocked", priority: "medium" },
|
|
{ id: issueC, companyId, identifier: "PAP-3", title: "Issue C", status: "blocked", priority: "medium" },
|
|
{ id: issueD, companyId, identifier: "PAP-4", title: "Issue D", status: "todo", priority: "high" },
|
|
]);
|
|
|
|
await svc.update(issueC, { blockedByIssueIds: [issueD] });
|
|
await svc.update(issueB, { blockedByIssueIds: [issueC] });
|
|
await svc.update(issueA, { blockedByIssueIds: [issueB] });
|
|
|
|
const relations = await svc.getRelationSummaries(issueA);
|
|
|
|
expect(relations.blockedBy).toHaveLength(1);
|
|
expect(relations.blockedBy[0]).toMatchObject({
|
|
id: issueB,
|
|
identifier: "PAP-2",
|
|
title: "Issue B",
|
|
terminalBlockers: [
|
|
expect.objectContaining({
|
|
id: issueD,
|
|
identifier: "PAP-4",
|
|
title: "Issue D",
|
|
status: "todo",
|
|
priority: "high",
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it("rejects blocking cycles", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const issueA = randomUUID();
|
|
const issueB = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: issueA, companyId, title: "Issue A", status: "todo", priority: "medium" },
|
|
{ id: issueB, companyId, title: "Issue B", status: "todo", priority: "medium" },
|
|
]);
|
|
|
|
await svc.update(issueA, { blockedByIssueIds: [issueB] });
|
|
|
|
await expect(
|
|
svc.update(issueB, { blockedByIssueIds: [issueA] }),
|
|
).rejects.toMatchObject({ status: 422 });
|
|
});
|
|
|
|
it("only returns dependents once every blocker is done", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, assigneeAgentId);
|
|
|
|
const blockerA = randomUUID();
|
|
const blockerB = randomUUID();
|
|
const blockedIssueId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: blockerA, companyId, title: "Blocker A", status: "done", priority: "medium", executionWorkspaceId: null },
|
|
{ id: blockerB, companyId, title: "Blocker B", status: "todo", priority: "medium", executionWorkspaceId: null },
|
|
{
|
|
id: blockedIssueId,
|
|
companyId,
|
|
title: "Blocked issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
]);
|
|
|
|
await svc.update(blockedIssueId, { blockedByIssueIds: [blockerA, blockerB] });
|
|
|
|
expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([]);
|
|
|
|
await svc.update(blockerB, { status: "done" });
|
|
|
|
const wakeable = await svc.listWakeableBlockedDependents(blockerA);
|
|
expect(wakeable).toEqual([
|
|
expect.objectContaining({
|
|
id: blockedIssueId,
|
|
assigneeAgentId,
|
|
blockerIssueIds: expect.arrayContaining([blockerA, blockerB]),
|
|
}),
|
|
]);
|
|
expect(wakeable[0]?.blockerIssueIds).toHaveLength(2);
|
|
});
|
|
|
|
it("treats done blockers on a shared workspace as ready while a foreign issue is in-flight", async () => {
|
|
const {
|
|
companyId,
|
|
executionWorkspaceId,
|
|
blockerId,
|
|
dependentId,
|
|
foreignIssueId,
|
|
} = await seedSharedWorkspaceDependency();
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: foreignIssueId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: dependentId,
|
|
blockerIssueIds: [blockerId],
|
|
}),
|
|
]);
|
|
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: true,
|
|
pendingFinalizeBlockerIssueIds: [],
|
|
unresolvedBlockerIssueIds: [],
|
|
});
|
|
});
|
|
|
|
it("keeps dependents blocked on unattributed workspace operations for the blocker workspace", async () => {
|
|
const {
|
|
companyId,
|
|
executionWorkspaceId,
|
|
blockerId,
|
|
dependentId,
|
|
} = await seedSharedWorkspaceDependency();
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: null,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([]);
|
|
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: false,
|
|
pendingFinalizeBlockerIssueIds: [blockerId],
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
});
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: null,
|
|
phase: "workspace_finalize",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: dependentId,
|
|
blockerIssueIds: [blockerId],
|
|
}),
|
|
]);
|
|
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: true,
|
|
pendingFinalizeBlockerIssueIds: [],
|
|
unresolvedBlockerIssueIds: [],
|
|
});
|
|
});
|
|
|
|
it("gates dependents on the blocker's own workspace-finalize barrier until sync-back succeeds", async () => {
|
|
const {
|
|
companyId,
|
|
executionWorkspaceId,
|
|
blockerId,
|
|
dependentId,
|
|
foreignIssueId,
|
|
assigneeAgentId,
|
|
} = await seedSharedWorkspaceDependency();
|
|
|
|
// The blocker touched its workspace but has not yet recorded
|
|
// workspace_finalize — the dependent must NOT wake.
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: blockerId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
|
|
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
|
|
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: false,
|
|
pendingFinalizeBlockerIssueIds: [blockerId],
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
});
|
|
|
|
// A failed finalize must keep the gate closed.
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: blockerId,
|
|
phase: "workspace_finalize",
|
|
status: "failed",
|
|
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
|
});
|
|
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
|
|
const pendingDependent = (await svc.list(companyId, { status: "blocked" }) as IssueListRow[])
|
|
.find((issue) => issue.id === dependentId);
|
|
expect(pendingDependent?.blockerAttention).toMatchObject({
|
|
state: "needs_attention",
|
|
unresolvedBlockerCount: 1,
|
|
attentionBlockerCount: 1,
|
|
pendingFinalizeBlockerIssueIds: [blockerId],
|
|
sampleBlockerIdentifier: "PAP-15043",
|
|
});
|
|
await expect(
|
|
svc.checkout(dependentId, assigneeAgentId, ["blocked"], null),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
details: {
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
unresolvedBlockers: [{
|
|
issueId: blockerId,
|
|
identifier: "PAP-15043",
|
|
title: "Predecessor",
|
|
reason: "pending_finalize",
|
|
}],
|
|
},
|
|
});
|
|
|
|
// A later successful finalize on the same workspace, even when attributed
|
|
// to another issue, proves the shared branch is coherent past the failure.
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: foreignIssueId,
|
|
phase: "workspace_finalize",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
|
});
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: null,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:15:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: dependentId,
|
|
assigneeAgentId,
|
|
blockerIssueIds: [blockerId],
|
|
}),
|
|
]);
|
|
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: true,
|
|
pendingFinalizeBlockerIssueIds: [],
|
|
});
|
|
});
|
|
|
|
it.each(["covered", "foreign-run", "unfinished", "failed-finalize", "late-finish", "later-operation"])(
|
|
"requires a successful owning finalize to cover a nested snapshot: %s", async kind => {
|
|
const f = await seedSharedWorkspaceDependency();
|
|
const runId = randomUUID(), foreignRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values([runId, foreignRunId].map(id => ({
|
|
id, companyId: f.companyId, agentId: f.assigneeAgentId, status: "succeeded",
|
|
})));
|
|
const started = new Date("2026-05-23T22:00:00.000Z");
|
|
const finished = new Date("2026-05-23T22:00:02.000Z");
|
|
await db.insert(workspaceOperations).values([
|
|
{ companyId: f.companyId, executionWorkspaceId: f.executionWorkspaceId, issueId: f.blockerId,
|
|
heartbeatRunId: runId, phase: "workspace_finalize",
|
|
status: kind === "failed-finalize" ? "failed" : "succeeded", startedAt: started, finishedAt: finished },
|
|
{ companyId: f.companyId, executionWorkspaceId: f.executionWorkspaceId, issueId: f.blockerId,
|
|
heartbeatRunId: kind === "foreign-run" ? foreignRunId : runId, phase: "remote_workspace_snapshot",
|
|
status: kind === "unfinished" ? "running" : "succeeded",
|
|
startedAt: new Date(started.getTime() + 100),
|
|
finishedAt: kind === "unfinished" ? null : new Date(finished.getTime() + (kind === "late-finish" ? 100 : -100)) },
|
|
...(kind === "later-operation" ? [{
|
|
companyId: f.companyId, executionWorkspaceId: f.executionWorkspaceId, issueId: f.blockerId,
|
|
heartbeatRunId: runId, phase: "worktree_prepare", status: "succeeded",
|
|
startedAt: new Date(finished.getTime() + 100), finishedAt: new Date(finished.getTime() + 200),
|
|
}] : []),
|
|
]);
|
|
const ready = kind === "covered";
|
|
expect((await svc.listWakeableBlockedDependents(f.blockerId)).map(issue => issue.id))
|
|
.toEqual(ready ? [f.dependentId] : []);
|
|
await expect(svc.getDependencyReadiness(f.dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: ready,
|
|
pendingFinalizeBlockerIssueIds: ready ? [] : [f.blockerId],
|
|
});
|
|
const listed = (await svc.list(f.companyId, { status: "blocked" }) as IssueListRow[]).find(issue => issue.id === f.dependentId);
|
|
expect(listed?.blockerAttention?.pendingFinalizeBlockerIssueIds ?? []).toEqual(ready ? [] : [f.blockerId]);
|
|
},
|
|
);
|
|
|
|
it("reports dependency readiness for blocked issue chains", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const blockerId = randomUUID();
|
|
const blockedId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium" },
|
|
{ id: blockedId, companyId, title: "Blocked", status: "todo", priority: "medium" },
|
|
]);
|
|
await svc.update(blockedId, { blockedByIssueIds: [blockerId] });
|
|
|
|
await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({
|
|
issueId: blockedId,
|
|
blockerIssueIds: [blockerId],
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
unresolvedBlockerCount: 1,
|
|
allBlockersDone: false,
|
|
isDependencyReady: false,
|
|
});
|
|
|
|
await svc.update(blockerId, { status: "done" });
|
|
|
|
await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({
|
|
issueId: blockedId,
|
|
blockerIssueIds: [blockerId],
|
|
unresolvedBlockerIssueIds: [],
|
|
unresolvedBlockerCount: 0,
|
|
allBlockersDone: true,
|
|
isDependencyReady: true,
|
|
});
|
|
});
|
|
|
|
it("unblocks a source issue when a liveness escalation recovery issue is marked done", async () => {
|
|
const companyId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
const sourceIssueId = randomUUID();
|
|
const recoveryIssueId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: sourceIssueId,
|
|
companyId,
|
|
title: "Source issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: recoveryIssueId,
|
|
companyId,
|
|
title: "Liveness escalation issue",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
originKind: "harness_liveness_escalation",
|
|
originId: `harness_liveness:${companyId}:${sourceIssueId}:invalid_review_participant:none`,
|
|
},
|
|
]);
|
|
|
|
await svc.update(sourceIssueId, {
|
|
blockedByIssueIds: [recoveryIssueId],
|
|
});
|
|
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
|
|
blockedBy: [expect.objectContaining({ id: recoveryIssueId })],
|
|
});
|
|
|
|
await svc.update(recoveryIssueId, {
|
|
status: "done",
|
|
});
|
|
|
|
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
|
|
blockedBy: [],
|
|
});
|
|
});
|
|
|
|
it("rejects execution when unresolved blockers remain", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, assigneeAgentId);
|
|
|
|
const blockerId = randomUUID();
|
|
const blockedId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: blockerId, companyId, identifier: "PAP-1", title: "Blocker", status: "todo", priority: "medium" },
|
|
{
|
|
id: blockedId,
|
|
companyId,
|
|
title: "Blocked",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
]);
|
|
await svc.update(blockedId, { blockedByIssueIds: [blockerId] });
|
|
|
|
await expect(
|
|
svc.update(blockedId, { status: "in_progress" }),
|
|
).rejects.toMatchObject({ status: 422 });
|
|
|
|
await expect(
|
|
svc.checkout(blockedId, assigneeAgentId, ["todo", "blocked"], null),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
details: {
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
unresolvedBlockers: [{
|
|
issueId: blockerId,
|
|
identifier: "PAP-1",
|
|
title: "Blocker",
|
|
reason: "not_done",
|
|
}],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("wakes parents only when all direct children are terminal", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, assigneeAgentId);
|
|
|
|
const parentId = randomUUID();
|
|
const childA = randomUUID();
|
|
const childB = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: parentId,
|
|
companyId,
|
|
title: "Parent issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
id: childA,
|
|
companyId,
|
|
parentId,
|
|
title: "Child A",
|
|
status: "done",
|
|
priority: "medium",
|
|
// Give the children distinct, ordered issue numbers. The service
|
|
// sorts direct children by issueNumber, then createdAt. A batched
|
|
// insert gives every row in the statement the same defaultNow()
|
|
// createdAt, so without a distinct issueNumber the two children
|
|
// tie on both sort keys and the database is free to return them
|
|
// in either order.
|
|
issueNumber: 1,
|
|
},
|
|
{
|
|
id: childB,
|
|
companyId,
|
|
parentId,
|
|
title: "Child B",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
issueNumber: 2,
|
|
},
|
|
]);
|
|
|
|
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toBeNull();
|
|
|
|
await svc.update(childB, { status: "cancelled" });
|
|
|
|
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toMatchObject({
|
|
id: parentId,
|
|
assigneeAgentId,
|
|
childIssueIds: [childA, childB],
|
|
childIssueSummaries: [
|
|
expect.objectContaining({ id: childA, title: "Child A", status: "done" }),
|
|
expect.objectContaining({ id: childB, title: "Child B", status: "cancelled" }),
|
|
],
|
|
childIssueSummaryTruncated: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("preserves the parent project when a generic child create inherits workspace linkage", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await seedWorkspaceProject(db, companyId, projectId);
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
},
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
title: "Generic child issue",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectId).toBe(projectId);
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
});
|
|
|
|
it("uses the target project's own workspaces for a cross-project child instead of inheriting the parent's", async () => {
|
|
const companyId = randomUUID();
|
|
const parentProjectId = randomUUID();
|
|
const targetProjectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const parentProjectWorkspaceId = randomUUID();
|
|
const targetProjectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(projects).values([
|
|
{ id: parentProjectId, companyId, name: "Paperclip App", status: "in_progress" },
|
|
{ id: targetProjectId, companyId, name: "Paperclip ID", status: "in_progress" },
|
|
]);
|
|
|
|
await db.insert(projectWorkspaces).values([
|
|
{
|
|
id: parentProjectWorkspaceId,
|
|
companyId,
|
|
projectId: parentProjectId,
|
|
name: "paperclip",
|
|
isPrimary: true,
|
|
},
|
|
{
|
|
id: targetProjectWorkspaceId,
|
|
companyId,
|
|
projectId: targetProjectId,
|
|
name: "paperclip-id",
|
|
isPrimary: true,
|
|
},
|
|
]);
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId: parentProjectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId: parentProjectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
title: "Google Workspace MCP",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
projectId: targetProjectId,
|
|
title: "Implement the Paperclip ID connect broker",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectId).toBe(targetProjectId);
|
|
expect(child.projectWorkspaceId).toBe(targetProjectWorkspaceId);
|
|
expect(child.executionWorkspaceId).not.toBe(executionWorkspaceId);
|
|
});
|
|
|
|
it("rejects explicitly pinned isolated git worktrees without a project or reusable workspace", async () => {
|
|
const companyId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await expect(svc.create(companyId, {
|
|
title: "Projectless isolated worktree",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "git_worktree" },
|
|
},
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
message: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
|
details: {
|
|
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
|
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not reject ambiguous inherited git-worktree settings before dispatch", async () => {
|
|
const companyId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
const issue = await svc.create(companyId, {
|
|
title: "Ambiguous inherited worktree",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceSettings: {
|
|
mode: "inherit",
|
|
workspaceStrategy: { type: "git_worktree" },
|
|
},
|
|
});
|
|
|
|
expect(issue.executionWorkspaceSettings).toEqual({
|
|
mode: "inherit",
|
|
workspaceStrategy: { type: "git_worktree" },
|
|
});
|
|
});
|
|
|
|
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const sourceIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await seedWorkspaceProject(db, companyId, projectId);
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "operator_branch",
|
|
strategyType: "git_worktree",
|
|
name: "Operator branch",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: sourceIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Source issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "operator_branch",
|
|
},
|
|
});
|
|
|
|
const followUp = await svc.create(companyId, {
|
|
title: "Follow-up issue",
|
|
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
|
});
|
|
|
|
expect(followUp.parentId).toBeNull();
|
|
expect(followUp.projectId).toBe(projectId);
|
|
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(followUp.executionWorkspaceSettings).toEqual({
|
|
mode: "operator_branch",
|
|
});
|
|
});
|
|
|
|
it("preserves projectless organization when an update adds workspace linkage", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await seedWorkspaceProject(db, companyId, projectId);
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Projectless issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
const updated = await svc.update(issueId, {
|
|
projectWorkspaceId,
|
|
});
|
|
|
|
expect(updated?.projectId).toBeNull();
|
|
expect(updated?.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
});
|
|
|
|
it("rejects updates that pin a projectless issue to an isolated git worktree", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Workspace Coder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const issue = await svc.create(companyId, {
|
|
title: "Assign then isolate",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await expect(svc.update(issue.id, {
|
|
assigneeAgentId: agentId,
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "git_worktree" },
|
|
},
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
message: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
|
details: {
|
|
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
|
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("syncs reused execution workspace config when issue workspace settings are updated", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await seedWorkspaceProject(db, companyId, projectId);
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
metadata: {
|
|
config: {
|
|
environmentId: "env-old",
|
|
provisionCommand: "bash ./scripts/provision-old.sh",
|
|
teardownCommand: "bash ./scripts/teardown-old.sh",
|
|
workspaceRuntime: { profile: "old" },
|
|
},
|
|
},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Recovery issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
environmentId: "env-old",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
provisionCommand: "bash ./scripts/provision-old.sh",
|
|
teardownCommand: "bash ./scripts/teardown-old.sh",
|
|
},
|
|
workspaceRuntime: { profile: "old" },
|
|
},
|
|
});
|
|
|
|
await svc.update(issueId, {
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
environmentId: "env-new",
|
|
workspaceStrategy: {
|
|
type: "cloud_sandbox",
|
|
provisionCommand: "bash ./scripts/provision-new.sh",
|
|
teardownCommand: "bash ./scripts/teardown-new.sh",
|
|
},
|
|
workspaceRuntime: { profile: "new" },
|
|
},
|
|
});
|
|
|
|
const workspace = await db
|
|
.select({ metadata: executionWorkspaces.metadata })
|
|
.from(executionWorkspaces)
|
|
.where(eq(executionWorkspaces.id, executionWorkspaceId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(workspace?.metadata).toMatchObject({
|
|
config: {
|
|
environmentId: null,
|
|
provisionCommand: "bash ./scripts/provision-new.sh",
|
|
runtimeProvisionCommand: null,
|
|
teardownCommand: "bash ./scripts/teardown-new.sh",
|
|
cleanupCommand: null,
|
|
workspaceRuntime: { profile: "new" },
|
|
desiredState: null,
|
|
serviceStates: null,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-mentioned-projects-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("can skip comment-body scans for bounded issue detail reads", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const titleProjectId = randomUUID();
|
|
const commentProjectId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(projects).values([
|
|
{
|
|
id: titleProjectId,
|
|
companyId,
|
|
name: "Title project",
|
|
status: "in_progress",
|
|
},
|
|
{
|
|
id: commentProjectId,
|
|
companyId,
|
|
name: "Comment project",
|
|
status: "in_progress",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: `Link [Title](${buildProjectMentionHref(titleProjectId)})`,
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId,
|
|
body: `Comment link [Comment](${buildProjectMentionHref(commentProjectId)})`,
|
|
});
|
|
|
|
expect(await svc.findMentionedProjectIds(issueId, { includeCommentBodies: false })).toEqual([titleProjectId]);
|
|
expect(await svc.findMentionedProjectIds(issueId)).toEqual([
|
|
titleProjectId,
|
|
commentProjectId,
|
|
]);
|
|
});
|
|
|
|
it("returns multiple same-company mentions in order, deduped", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const firstProjectId = randomUUID();
|
|
const secondProjectId = randomUUID();
|
|
const thirdProjectId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
|
|
await db.insert(projects).values([
|
|
{ id: firstProjectId, companyId, name: "First project", status: "in_progress" },
|
|
{ id: secondProjectId, companyId, name: "Second project", status: "in_progress" },
|
|
{ id: thirdProjectId, companyId, name: "Third project", status: "in_progress" },
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title:
|
|
`See [First](${buildProjectMentionHref(firstProjectId)}) and ` +
|
|
`[Second](${buildProjectMentionHref(secondProjectId)})`,
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId,
|
|
// Repeats the first mention (deduped) and introduces a third.
|
|
body:
|
|
`Also [First again](${buildProjectMentionHref(firstProjectId)}) and ` +
|
|
`[Third](${buildProjectMentionHref(thirdProjectId)})`,
|
|
});
|
|
|
|
expect(await svc.findMentionedProjectIds(issueId)).toEqual([
|
|
firstProjectId,
|
|
secondProjectId,
|
|
thirdProjectId,
|
|
]);
|
|
});
|
|
|
|
it("filters out a mention from another company", async () => {
|
|
const companyId = randomUUID();
|
|
const foreignCompanyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const sameCompanyProjectId = randomUUID();
|
|
const foreignProjectId = randomUUID();
|
|
|
|
await db.insert(companies).values([
|
|
{
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
},
|
|
{
|
|
id: foreignCompanyId,
|
|
name: "Other company",
|
|
issuePrefix: `F${foreignCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
},
|
|
]);
|
|
|
|
await db.insert(projects).values([
|
|
{ id: sameCompanyProjectId, companyId, name: "Same-company project", status: "in_progress" },
|
|
{ id: foreignProjectId, companyId: foreignCompanyId, name: "Foreign project", status: "in_progress" },
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title:
|
|
`Ours [Same](${buildProjectMentionHref(sameCompanyProjectId)}) and ` +
|
|
`theirs [Foreign](${buildProjectMentionHref(foreignProjectId)})`,
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
expect(await svc.findMentionedProjectIds(issueId)).toEqual([sameCompanyProjectId]);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-execution-lock-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(goals);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedIssueWithRun(status: string | null) {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = status ? randomUUID() : null;
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
if (runId && status) {
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status,
|
|
invocationSource: "manual",
|
|
});
|
|
}
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Execution lock",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
executionRunId: runId,
|
|
executionAgentNameKey: runId ? "codexcoder" : null,
|
|
executionLockedAt: runId ? new Date() : null,
|
|
});
|
|
|
|
return { issueId, runId };
|
|
}
|
|
|
|
it("clears execution locks owned by terminal runs", async () => {
|
|
const { issueId } = await seedIssueWithRun("failed");
|
|
|
|
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(true);
|
|
|
|
const row = await db
|
|
.select({
|
|
executionRunId: issues.executionRunId,
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
executionLockedAt: issues.executionLockedAt,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
});
|
|
});
|
|
|
|
it("does not clear execution locks owned by live runs", async () => {
|
|
const { issueId, runId } = await seedIssueWithRun("running");
|
|
|
|
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(false);
|
|
|
|
const row = await db
|
|
.select({
|
|
executionRunId: issues.executionRunId,
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
executionLockedAt: issues.executionLockedAt,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row?.executionRunId).toBe(runId);
|
|
expect(row?.executionAgentNameKey).toBe("codexcoder");
|
|
expect(row?.executionLockedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("does not update issues without an execution lock", async () => {
|
|
const { issueId } = await seedIssueWithRun(null);
|
|
|
|
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(false);
|
|
|
|
const row = await db
|
|
.select({ executionRunId: issues.executionRunId, executionLockedAt: issues.executionLockedAt })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({ executionRunId: null, executionLockedAt: null });
|
|
});
|
|
|
|
it("does not clear checkout locks when a different execution run is live", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const failedRunId = randomUUID();
|
|
const runningRunId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: failedRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "failed",
|
|
invocationSource: "manual",
|
|
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
|
|
},
|
|
{
|
|
id: runningRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-06-10T10:06:00.000Z"),
|
|
},
|
|
]);
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Mixed execution lock",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: failedRunId,
|
|
executionRunId: runningRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date("2026-06-10T10:06:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.clearCheckoutRunIfTerminal(issueId)).resolves.toBe(false);
|
|
|
|
const row = await db
|
|
.select({
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
executionLockedAt: issues.executionLockedAt,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row?.checkoutRunId).toBe(failedRunId);
|
|
expect(row?.executionRunId).toBe(runningRunId);
|
|
expect(row?.executionAgentNameKey).toBe("codexcoder");
|
|
expect(row?.executionLockedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("does not let stale release clobber a successor checkout lock", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const failedRunId = randomUUID();
|
|
const releasingRunId = randomUUID();
|
|
const successorRunId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: failedRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "failed",
|
|
invocationSource: "manual",
|
|
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
|
|
},
|
|
{
|
|
id: releasingRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-06-10T10:06:00.000Z"),
|
|
},
|
|
{
|
|
id: successorRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-06-10T10:07:00.000Z"),
|
|
},
|
|
]);
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Race stale release",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: failedRunId,
|
|
executionRunId: failedRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date("2026-06-10T10:00:00.000Z"),
|
|
});
|
|
|
|
const [releaseResult, checkoutResult] = await Promise.allSettled([
|
|
svc.release(issueId, agentId, releasingRunId),
|
|
svc.checkout(issueId, agentId, ["todo", "in_progress"], successorRunId),
|
|
]);
|
|
|
|
expect(checkoutResult.status).toBe("fulfilled");
|
|
if (releaseResult.status === "rejected") {
|
|
expect(releaseResult.reason).toMatchObject({ status: 409 });
|
|
}
|
|
|
|
const row = await db
|
|
.select({
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toMatchObject({
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: successorRunId,
|
|
executionRunId: successorRunId,
|
|
});
|
|
});
|
|
|
|
it("checkout refuses to promote a 'done' issue when 'done' is not in expectedStatuses, even with a lingering executionRunId pointer", async () => {
|
|
// Regression for PR #2482 checkout-adoption review finding: the original
|
|
// patch's stale-executionRunId adoption SQL set `status: 'in_progress'`
|
|
// unconditionally, bypassing the caller's expectedStatuses guard. With the
|
|
// guard restored, attempting to take over a 'done' issue with
|
|
// expectedStatuses=['todo'] must fail and leave the row untouched.
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const failedRunId = randomUUID();
|
|
const successorRunId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: failedRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "failed",
|
|
invocationSource: "manual",
|
|
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
|
|
},
|
|
{
|
|
id: successorRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-06-10T10:07:00.000Z"),
|
|
},
|
|
]);
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Stale lock on done issue",
|
|
status: "done",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
executionRunId: failedRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date("2026-06-10T10:00:00.000Z"),
|
|
completedAt: new Date("2026-06-10T10:01:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.checkout(issueId, agentId, ["todo"], successorRunId))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
|
|
const row = await db
|
|
.select({
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toMatchObject({
|
|
status: "done",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: null,
|
|
});
|
|
});
|
|
|
|
it("checkout adoption of a stale checkoutRunId preserves the issue's assigneeUserId", async () => {
|
|
// Regression for PR #2482 checkout-adoption review finding: any adoption
|
|
// helper that re-locks an existing in_progress issue (e.g. when the prior
|
|
// checkout/execution run is terminal) must not strip the row's
|
|
// assigneeUserId. We exercise this via the adoptStaleCheckoutRun path,
|
|
// which fires when checkoutRunId points at a terminal run while
|
|
// executionRunId still points at a different, non-terminal run.
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const userId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const failedCheckoutRunId = randomUUID();
|
|
const queuedExecutionRunId = randomUUID();
|
|
const successorRunId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await seedCodexCoder(db, companyId, agentId);
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: failedCheckoutRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "failed",
|
|
invocationSource: "manual",
|
|
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
|
|
},
|
|
{
|
|
id: queuedExecutionRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "queued",
|
|
invocationSource: "manual",
|
|
},
|
|
{
|
|
id: successorRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-06-10T10:07:00.000Z"),
|
|
},
|
|
]);
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Stale checkout lock with user co-assignee",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: userId,
|
|
checkoutRunId: failedCheckoutRunId,
|
|
executionRunId: queuedExecutionRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date("2026-06-10T10:00:00.000Z"),
|
|
});
|
|
|
|
const result = await svc.checkout(issueId, agentId, ["todo", "in_progress"], successorRunId);
|
|
expect(result).toBeTruthy();
|
|
|
|
const row = await db
|
|
.select({
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toMatchObject({
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: userId,
|
|
checkoutRunId: successorRunId,
|
|
executionRunId: successorRunId,
|
|
});
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("accepted plan decomposition", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-accepted-plan-decomposition-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issuePlanDecompositions);
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(issueDocuments);
|
|
await db.delete(documentRevisions);
|
|
await db.delete(documents);
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(goals);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedAcceptedPlanContext() {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await seedCodexCoder(db, companyId, assigneeAgentId);
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Accepted plan decomposition",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
|
|
return { companyId, goalId, assigneeAgentId };
|
|
}
|
|
|
|
async function seedAcceptedPlanIssue(args?: {
|
|
companyId?: string;
|
|
goalId?: string;
|
|
assigneeAgentId?: string;
|
|
sourceIssueId?: string;
|
|
issueTitle?: string;
|
|
workMode?: IssueWorkMode;
|
|
}) {
|
|
const companyId = args?.companyId ?? randomUUID();
|
|
const goalId = args?.goalId ?? randomUUID();
|
|
const assigneeAgentId = args?.assigneeAgentId ?? randomUUID();
|
|
const sourceIssueId = args?.sourceIssueId ?? randomUUID();
|
|
const planDocumentId = randomUUID();
|
|
const acceptedPlanRevisionId = randomUUID();
|
|
const acceptedInteractionId = randomUUID();
|
|
|
|
if (!args?.companyId || !args?.goalId || !args?.assigneeAgentId) {
|
|
await seedIssueCompany(db, companyId);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await seedCodexCoder(db, companyId, assigneeAgentId);
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Accepted plan decomposition",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
}
|
|
|
|
await db.insert(issues).values({
|
|
id: sourceIssueId,
|
|
companyId,
|
|
goalId,
|
|
title: args?.issueTitle ?? "Planning issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
workMode: args?.workMode ?? "planning",
|
|
assigneeAgentId: assigneeAgentId,
|
|
});
|
|
await db.insert(documents).values({
|
|
id: planDocumentId,
|
|
companyId,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
latestBody: "Plan body",
|
|
latestRevisionId: acceptedPlanRevisionId,
|
|
latestRevisionNumber: 1,
|
|
createdByAgentId: assigneeAgentId,
|
|
updatedByAgentId: assigneeAgentId,
|
|
});
|
|
await db.insert(documentRevisions).values({
|
|
id: acceptedPlanRevisionId,
|
|
companyId,
|
|
documentId: planDocumentId,
|
|
revisionNumber: 1,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "Plan body",
|
|
createdByAgentId: assigneeAgentId,
|
|
});
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId: sourceIssueId,
|
|
documentId: planDocumentId,
|
|
key: "plan",
|
|
});
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: acceptedInteractionId,
|
|
companyId,
|
|
issueId: sourceIssueId,
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve this plan?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: sourceIssueId,
|
|
documentId: planDocumentId,
|
|
key: "plan",
|
|
revisionId: acceptedPlanRevisionId,
|
|
revisionNumber: 1,
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "accepted",
|
|
},
|
|
resolvedAt: new Date(),
|
|
createdByUserId: "local-board",
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
|
|
return { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId };
|
|
}
|
|
|
|
async function getAcceptedPlanClaim(sourceIssueId: string) {
|
|
return db
|
|
.select()
|
|
.from(issuePlanDecompositions)
|
|
.where(eq(issuePlanDecompositions.sourceIssueId, sourceIssueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
it("reuses the same child issue set on repeat decomposition attempts for an accepted plan revision", async () => {
|
|
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
|
|
const children = [
|
|
{
|
|
title: "Implement the claim table",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
title: "Add decomposition route tests",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
];
|
|
|
|
const first = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
expect(first.decomposition).not.toHaveProperty("requestedChildren");
|
|
expect(first.childIssueIds).toHaveLength(2);
|
|
expect(first.newlyCreatedIssues).toHaveLength(2);
|
|
expect(first.decomposition.status).toBe("completed");
|
|
|
|
const second = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
expect(second.childIssueIds).toEqual(first.childIssueIds);
|
|
expect(second.newlyCreatedIssues).toHaveLength(0);
|
|
expect(second.decomposition.status).toBe("completed");
|
|
|
|
const persistedClaims = await db
|
|
.select()
|
|
.from(issuePlanDecompositions)
|
|
.where(eq(issuePlanDecompositions.sourceIssueId, sourceIssueId));
|
|
expect(persistedClaims).toHaveLength(1);
|
|
expect(persistedClaims[0]?.requestedChildCount).toBe(2);
|
|
expect(persistedClaims[0]?.childIssueIds).toEqual(first.childIssueIds);
|
|
|
|
const childrenRows = await db
|
|
.select({ id: issues.id, title: issues.title })
|
|
.from(issues)
|
|
.where(eq(issues.parentId, sourceIssueId));
|
|
expect(childrenRows).toHaveLength(2);
|
|
expect(childrenRows.map((row) => row.id).sort()).toEqual([...first.childIssueIds].sort());
|
|
|
|
const companyIssues = await svc.list(companyId, { parentId: sourceIssueId });
|
|
expect(companyIssues).toHaveLength(2);
|
|
});
|
|
|
|
it("rejects a different child set for the same accepted plan fingerprint", async () => {
|
|
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
|
|
await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "Implement the claim table",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
await expect(svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "Implement the claim table",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
title: "This duplicate should be rejected",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
});
|
|
});
|
|
|
|
it("allows accepted-plan decomposition on a standard-work issue with an accepted plan document", async () => {
|
|
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue({
|
|
workMode: "standard",
|
|
issueTitle: "Implement after planning",
|
|
});
|
|
|
|
const result = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "Implement the approved first slice",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
expect(result.childIssueIds).toHaveLength(1);
|
|
expect(result.newlyCreatedIssues).toHaveLength(1);
|
|
expect(result.decomposition.status).toBe("completed");
|
|
});
|
|
|
|
it("serializes concurrent accepted-plan retries for the same parent issue without duplicate children", async () => {
|
|
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
const children = [
|
|
{
|
|
title: "Persist exact-once decomposition claim",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
{
|
|
title: "Guard concurrent retry callers",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
];
|
|
|
|
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
|
expect(claim).not.toBeNull();
|
|
|
|
for (const childIssueId of initial.childIssueIds) {
|
|
await db.delete(issues).where(eq(issues.id, childIssueId));
|
|
}
|
|
await db
|
|
.update(issuePlanDecompositions)
|
|
.set({
|
|
status: "in_flight",
|
|
childIssueIds: [],
|
|
completedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issuePlanDecompositions.id, claim!.id));
|
|
|
|
const svcA = issueService(db);
|
|
const svcB = issueService(db);
|
|
const [first, second] = await Promise.all([
|
|
svcA.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
}),
|
|
svcB.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
}),
|
|
]);
|
|
|
|
expect(first.childIssueIds).toEqual(second.childIssueIds);
|
|
expect(first.childIssueIds).toHaveLength(2);
|
|
expect(first.newlyCreatedIssues.length + second.newlyCreatedIssues.length).toBe(2);
|
|
|
|
const persistedClaim = await getAcceptedPlanClaim(sourceIssueId);
|
|
expect(persistedClaim?.status).toBe("completed");
|
|
expect(persistedClaim?.childIssueIds).toEqual(first.childIssueIds);
|
|
|
|
const childrenRows = await db
|
|
.select({ id: issues.id, title: issues.title })
|
|
.from(issues)
|
|
.where(eq(issues.parentId, sourceIssueId));
|
|
expect(childrenRows).toHaveLength(2);
|
|
expect(childrenRows.map((row) => row.id).sort()).toEqual([...first.childIssueIds].sort());
|
|
});
|
|
|
|
it("rejects another planning parent's accepted revision even when both issues share the assignee", async () => {
|
|
const { companyId, goalId, assigneeAgentId } = await seedAcceptedPlanContext();
|
|
const firstIssue = await seedAcceptedPlanIssue({
|
|
companyId,
|
|
goalId,
|
|
assigneeAgentId,
|
|
issueTitle: "Earlier accepted plan",
|
|
});
|
|
const secondIssue = await seedAcceptedPlanIssue({
|
|
companyId,
|
|
goalId,
|
|
assigneeAgentId,
|
|
issueTitle: "Later accepted plan",
|
|
});
|
|
|
|
await svc.decomposeAcceptedPlan(firstIssue.sourceIssueId, {
|
|
acceptedPlanRevisionId: firstIssue.acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "Decompose the first issue only",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
await expect(svc.decomposeAcceptedPlan(secondIssue.sourceIssueId, {
|
|
acceptedPlanRevisionId: firstIssue.acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "This must not land on the second parent",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
});
|
|
|
|
const secondIssueChildren = await db
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(eq(issues.parentId, secondIssue.sourceIssueId));
|
|
expect(secondIssueChildren).toHaveLength(0);
|
|
});
|
|
|
|
it("resumes partial child creation under the claimed fingerprint without duplicating completed children", async () => {
|
|
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
const children = [
|
|
{
|
|
title: "Create the first child once",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
{
|
|
title: "Recreate only the missing tail child",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
];
|
|
|
|
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
|
expect(claim).not.toBeNull();
|
|
|
|
const [firstChildId, secondChildId] = initial.childIssueIds;
|
|
expect(firstChildId).toBeTruthy();
|
|
expect(secondChildId).toBeTruthy();
|
|
|
|
await db.delete(issues).where(eq(issues.id, secondChildId!));
|
|
await db
|
|
.update(issuePlanDecompositions)
|
|
.set({
|
|
status: "in_flight",
|
|
childIssueIds: [firstChildId!],
|
|
completedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issuePlanDecompositions.id, claim!.id));
|
|
|
|
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
expect(retried.decomposition.status).toBe("completed");
|
|
expect(retried.childIssueIds[0]).toBe(firstChildId);
|
|
expect(retried.newlyCreatedIssues).toHaveLength(1);
|
|
expect(retried.newlyCreatedIssues[0]?.title).toBe("Recreate only the missing tail child");
|
|
|
|
const childrenRows = await db
|
|
.select({ id: issues.id, title: issues.title })
|
|
.from(issues)
|
|
.where(eq(issues.parentId, sourceIssueId));
|
|
expect(childrenRows).toHaveLength(2);
|
|
expect(childrenRows.some((row) => row.id === firstChildId)).toBe(true);
|
|
expect(childrenRows.map((row) => row.title).sort()).toEqual(children.map((child) => child.title).sort());
|
|
});
|
|
|
|
it("resumes a partial decomposition after reassignment when only actor metadata changes", async () => {
|
|
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
const reassignedAgentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: reassignedAgentId,
|
|
companyId,
|
|
name: "SecondCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const children = [
|
|
{
|
|
title: "Keep the original child",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
createdByAgentId: assigneeAgentId,
|
|
actorAgentId: assigneeAgentId,
|
|
},
|
|
{
|
|
title: "Create only the missing child after reassignment",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
createdByAgentId: assigneeAgentId,
|
|
actorAgentId: assigneeAgentId,
|
|
},
|
|
];
|
|
|
|
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
|
const [firstChildId, secondChildId] = initial.childIssueIds;
|
|
|
|
expect(claim).not.toBeNull();
|
|
expect(firstChildId).toBeTruthy();
|
|
expect(secondChildId).toBeTruthy();
|
|
|
|
await db.delete(issues).where(eq(issues.id, secondChildId!));
|
|
await db
|
|
.update(issues)
|
|
.set({ assigneeAgentId: reassignedAgentId, updatedAt: new Date() })
|
|
.where(eq(issues.id, sourceIssueId));
|
|
await db
|
|
.update(issuePlanDecompositions)
|
|
.set({
|
|
status: "in_flight",
|
|
childIssueIds: [firstChildId!],
|
|
completedAt: null,
|
|
ownerAgentId: assigneeAgentId,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issuePlanDecompositions.id, claim!.id));
|
|
|
|
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children: children.map((child) => ({
|
|
...child,
|
|
createdByAgentId: reassignedAgentId,
|
|
actorAgentId: reassignedAgentId,
|
|
})),
|
|
actorAgentId: reassignedAgentId,
|
|
});
|
|
|
|
expect(retried.decomposition.status).toBe("completed");
|
|
expect(retried.decomposition.ownerAgentId).toBe(reassignedAgentId);
|
|
expect(retried.childIssueIds[0]).toBe(firstChildId);
|
|
expect(retried.newlyCreatedIssues).toHaveLength(1);
|
|
expect(retried.newlyCreatedIssues[0]?.title).toBe("Create only the missing child after reassignment");
|
|
|
|
const childrenRows = await db
|
|
.select({ id: issues.id, title: issues.title, createdByAgentId: issues.createdByAgentId })
|
|
.from(issues)
|
|
.where(eq(issues.parentId, sourceIssueId))
|
|
.orderBy(asc(issues.createdAt), asc(issues.id));
|
|
expect(childrenRows).toHaveLength(2);
|
|
expect(childrenRows.map((row) => row.id).sort()).toEqual([...retried.childIssueIds].sort());
|
|
expect(childrenRows.find((row) => row.id !== firstChildId)?.createdByAgentId).toBe(reassignedAgentId);
|
|
});
|
|
|
|
it("preserves the existing live claim owner when another actor resumes the same fingerprint", async () => {
|
|
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
const competingAgentId = randomUUID();
|
|
const liveOwnerRunId = randomUUID();
|
|
const competingRunId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: competingAgentId,
|
|
companyId,
|
|
name: "SecondCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: liveOwnerRunId,
|
|
companyId,
|
|
agentId: assigneeAgentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
},
|
|
{
|
|
id: competingRunId,
|
|
companyId,
|
|
agentId: competingAgentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
},
|
|
]);
|
|
|
|
const children = [
|
|
{
|
|
title: "Keep the first created child",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
{
|
|
title: "Create the missing second child",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
];
|
|
|
|
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
actorRunId: liveOwnerRunId,
|
|
});
|
|
const [firstChildId, secondChildId] = initial.childIssueIds;
|
|
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
|
|
|
await db.delete(issues).where(eq(issues.id, secondChildId!));
|
|
await db
|
|
.update(issuePlanDecompositions)
|
|
.set({
|
|
status: "in_flight",
|
|
childIssueIds: [firstChildId!],
|
|
completedAt: null,
|
|
ownerAgentId: assigneeAgentId,
|
|
ownerRunId: liveOwnerRunId,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issuePlanDecompositions.id, claim!.id));
|
|
|
|
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: competingAgentId,
|
|
actorRunId: competingRunId,
|
|
});
|
|
|
|
expect(retried.decomposition.status).toBe("completed");
|
|
expect(retried.decomposition.ownerAgentId).toBe(assigneeAgentId);
|
|
expect(retried.decomposition.ownerRunId).toBe(liveOwnerRunId);
|
|
});
|
|
|
|
it("lists persisted decompositions with child issue summaries", async () => {
|
|
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
|
|
const initial = await svc.listAcceptedPlanDecompositions(sourceIssueId);
|
|
expect(initial).toEqual([]);
|
|
|
|
const result = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "Surface decomposition status in operator UI",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
title: "Add regression coverage",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
const decompositions = await svc.listAcceptedPlanDecompositions(sourceIssueId);
|
|
expect(decompositions).toHaveLength(1);
|
|
const [record] = decompositions;
|
|
expect(record?.status).toBe("completed");
|
|
expect(record?.acceptedPlanRevisionId).toBe(acceptedPlanRevisionId);
|
|
expect(record?.acceptedPlanRevisionNumber).toBeTypeOf("number");
|
|
expect(record?.childIssues.map((child) => child.id).sort()).toEqual(
|
|
[...result.childIssueIds].sort(),
|
|
);
|
|
expect(record).not.toHaveProperty("requestedChildren");
|
|
expect(record?.childIssues.every((child) => typeof child.title === "string")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adoption", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-checkout-owner-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(goals);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedOwnershipIssue(params: {
|
|
checkoutStatus: "running" | "failed" | "timed_out";
|
|
actorRunStatus?: "running" | "failed" | "timed_out" | "succeeded";
|
|
assigneeMatchesActor?: boolean;
|
|
}) {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const actorAgentId = params.assigneeMatchesActor === false ? randomUUID() : assigneeAgentId;
|
|
const staleRunId = randomUUID();
|
|
const actorRunId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const actorRunStatus = params.actorRunStatus ?? "running";
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
const agentRows = [
|
|
{
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "Assignee",
|
|
role: "engineer" as const,
|
|
status: "active" as const,
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
];
|
|
if (actorAgentId !== assigneeAgentId) {
|
|
agentRows.push({
|
|
id: actorAgentId,
|
|
companyId,
|
|
name: "Actor",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
}
|
|
await db.insert(agents).values(agentRows);
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: staleRunId,
|
|
companyId,
|
|
agentId: assigneeAgentId,
|
|
status: params.checkoutStatus,
|
|
invocationSource: "manual",
|
|
finishedAt: params.checkoutStatus === "running" ? null : new Date(),
|
|
},
|
|
{
|
|
id: actorRunId,
|
|
companyId,
|
|
agentId: actorAgentId,
|
|
status: actorRunStatus,
|
|
invocationSource: "manual",
|
|
startedAt: actorRunStatus === "running" ? new Date() : null,
|
|
finishedAt: actorRunStatus === "running" ? null : new Date(),
|
|
},
|
|
]);
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Checkout owner recovery",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId,
|
|
checkoutRunId: staleRunId,
|
|
executionRunId: staleRunId,
|
|
executionLockedAt: new Date(),
|
|
executionAgentNameKey: "assignee",
|
|
});
|
|
|
|
return { issueId, assigneeAgentId, actorAgentId, staleRunId, actorRunId };
|
|
}
|
|
|
|
it.each(["succeeded", "failed"])("preserves a human handoff when the outgoing legacy run later %s", async status => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
|
|
const original = await svc.getById(seeded.issueId);
|
|
await db.insert(companyMemberships).values({
|
|
companyId: original!.companyId,
|
|
principalType: "user",
|
|
principalId: "handoff-reviewer",
|
|
status: "active",
|
|
});
|
|
await db.update(heartbeatRuns).set({
|
|
contextSnapshot: { issueId: seeded.issueId },
|
|
}).where(eq(heartbeatRuns.id, seeded.actorRunId));
|
|
await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
|
|
|
|
const handoff = await svc.update(seeded.issueId, {
|
|
assigneeAgentId: null,
|
|
assigneeUserId: "handoff-reviewer",
|
|
status: "in_review",
|
|
actorAgentId: seeded.actorAgentId,
|
|
actorRunId: seeded.actorRunId,
|
|
});
|
|
expect(handoff).toMatchObject({
|
|
assigneeAgentId: null,
|
|
assigneeUserId: "handoff-reviewer",
|
|
status: "in_review",
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
});
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.actorRunId));
|
|
expect(run!.status).toBe("running");
|
|
await expect(svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
await expect(svc.update(seeded.issueId, {
|
|
title: "Late outgoing edit",
|
|
actorAgentId: seeded.actorAgentId,
|
|
actorRunId: seeded.actorRunId,
|
|
})).rejects.toMatchObject({ status: 403, details: { code: "issue_handed_off" } });
|
|
|
|
await terminalizeLegacyExecution({
|
|
db,
|
|
run: run!,
|
|
status,
|
|
fromStatuses: ["running"],
|
|
reconcileIfNeeded: true,
|
|
patch: { finishedAt: new Date(), ...(status === "failed" ? { errorCode: "process_lost" } : {}) },
|
|
});
|
|
await settleUnrecoverableExecutions(db);
|
|
await expect(svc.update(seeded.issueId, {
|
|
status: "done",
|
|
actorAgentId: seeded.actorAgentId,
|
|
actorRunId: seeded.actorRunId,
|
|
})).rejects.toMatchObject({ status: 403, details: { code: "issue_handed_off" } });
|
|
expect(await svc.getById(seeded.issueId)).toMatchObject({
|
|
status: "in_review",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: "handoff-reviewer",
|
|
});
|
|
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, seeded.issueId)))
|
|
.toHaveLength(0);
|
|
});
|
|
|
|
it("lets the current assignee adopt a stale terminal checkout owner", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
|
|
|
|
const ownership = await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
|
|
|
|
expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
|
|
expect(ownership.executionRunId).toBe(seeded.actorRunId);
|
|
expect(ownership.adoptedFromRunId).toBeNull();
|
|
});
|
|
|
|
it("treats timed_out checkout owners as stale and recoverable", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "timed_out" });
|
|
|
|
const ownership = await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
|
|
|
|
expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
|
|
expect(ownership.adoptedFromRunId).toBeNull();
|
|
});
|
|
|
|
it("does not allow non-assignees to adopt stale checkout ownership", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed", assigneeMatchesActor: false });
|
|
|
|
await expect(
|
|
svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
|
|
).rejects.toMatchObject({ status: 409 });
|
|
});
|
|
|
|
it("keeps live checkout owners protected with a 409 conflict", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "running" });
|
|
|
|
await expect(
|
|
svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
|
|
).rejects.toMatchObject({ status: 409 });
|
|
});
|
|
|
|
it("does not let terminal actor runs adopt stale checkout ownership", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed", actorRunStatus: "succeeded" });
|
|
|
|
await expect(
|
|
svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
|
|
).rejects.toMatchObject({ status: 409 });
|
|
|
|
const row = await db
|
|
.select({
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, seeded.issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
});
|
|
});
|
|
|
|
it("adopts unowned checkout after a concurrent stale-checkout clear wins the lock race", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
executionRunId: seeded.actorRunId,
|
|
executionLockedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, seeded.issueId));
|
|
|
|
const rowLocked = deferred<void>();
|
|
const clearCanCommit = deferred<void>();
|
|
|
|
const concurrentClear = db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select ${issues.id} from ${issues} where ${issues.id} = ${seeded.issueId} for update`,
|
|
);
|
|
rowLocked.resolve();
|
|
await clearCanCommit.promise;
|
|
await tx
|
|
.update(issues)
|
|
.set({
|
|
checkoutRunId: null,
|
|
executionRunId: seeded.actorRunId,
|
|
executionLockedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, seeded.issueId));
|
|
});
|
|
|
|
await rowLocked.promise;
|
|
|
|
const ownershipPromise = svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
clearCanCommit.resolve();
|
|
await concurrentClear;
|
|
|
|
const ownership = await ownershipPromise;
|
|
expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
|
|
expect(ownership.executionRunId).toBe(seeded.actorRunId);
|
|
expect(ownership.adoptedFromRunId).toBeNull();
|
|
|
|
const row = await db
|
|
.select({
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, seeded.issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
checkoutRunId: seeded.actorRunId,
|
|
executionRunId: seeded.actorRunId,
|
|
});
|
|
});
|
|
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.addComment createdByRunId", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let singleConnectionDb!: ReturnType<typeof createDb>;
|
|
let singleConnectionSvc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
let companyId!: string;
|
|
let agentId!: string;
|
|
let issueId!: string;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-comment-runid-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
singleConnectionDb = createDb(tempDb.connectionString, { maxConnections: 1 });
|
|
singleConnectionSvc = issueService(singleConnectionDb);
|
|
|
|
companyId = randomUUID();
|
|
agentId = randomUUID();
|
|
issueId = randomUUID();
|
|
|
|
await seedIssueCompany(db, companyId);
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "TestAgent",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Test issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
}, 20_000);
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function createdByRunIdFor(commentId: string) {
|
|
return db
|
|
.select({ createdByRunId: issueComments.createdByRunId })
|
|
.from(issueComments)
|
|
.where(eq(issueComments.id, commentId))
|
|
.then((rows) => rows[0]?.createdByRunId ?? null);
|
|
}
|
|
|
|
it("keeps addComment transaction reads on a single pooled connection", async () => {
|
|
const comment = await singleConnectionDb.transaction(async (tx) =>
|
|
singleConnectionSvc.addComment(
|
|
issueId,
|
|
"transaction-safe comment",
|
|
{},
|
|
{ authorType: "system" },
|
|
tx,
|
|
),
|
|
);
|
|
|
|
expect(comment.body).toBe("transaction-safe comment");
|
|
});
|
|
|
|
it("nulls out a non-UUID x-paperclip-run-id instead of 500-ing", async () => {
|
|
const comment = await svc.addComment(issueId, "hello from a synthetic run id", {
|
|
runId: "client-request-abc123",
|
|
});
|
|
|
|
expect(comment.id).toBeTruthy();
|
|
expect(await createdByRunIdFor(comment.id)).toBeNull();
|
|
});
|
|
|
|
it("nulls out a UUID runId absent from heartbeat_runs instead of 500-ing", async () => {
|
|
const comment = await svc.addComment(issueId, "hello from a stale run", {
|
|
runId: randomUUID(),
|
|
});
|
|
|
|
expect(comment.id).toBeTruthy();
|
|
expect(await createdByRunIdFor(comment.id)).toBeNull();
|
|
});
|
|
|
|
it("preserves a valid runId that exists in heartbeat_runs for the company", async () => {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
});
|
|
|
|
const comment = await svc.addComment(issueId, "hello from a live run", { runId });
|
|
|
|
expect(await createdByRunIdFor(comment.id)).toBe(runId);
|
|
});
|
|
|
|
it("deduplicates concurrent identical comments from the same run", async () => {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
});
|
|
|
|
const [first, second] = await Promise.all([
|
|
svc.addComment(issueId, "one durable result", { agentId, runId }),
|
|
svc.addComment(issueId, "one durable result", { agentId, runId }),
|
|
]);
|
|
|
|
expect(second.id).toBe(first.id);
|
|
const duplicates = await db
|
|
.select({ id: issueComments.id })
|
|
.from(issueComments)
|
|
.where(eq(issueComments.createdByRunId, runId));
|
|
expect(duplicates).toHaveLength(1);
|
|
});
|
|
});
|