mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
merge: reconcile callback bridge fixtures with current master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
10a873b76c
51 files changed
+1643
-140
No files matched your search
@@ -59,6 +59,7 @@ interface IssueCreateOptions extends BaseClientOptions {
|
||||
projectId?: string;
|
||||
goalId?: string;
|
||||
parentId?: string;
|
||||
standalone?: boolean;
|
||||
requestDepth?: string;
|
||||
billingCode?: string;
|
||||
}
|
||||
@@ -286,10 +287,17 @@ export function registerIssueCommands(program: Command): void {
|
||||
.option("--project-id <id>", "Project ID")
|
||||
.option("--goal-id <id>", "Goal ID")
|
||||
.option("--parent-id <id>", "Parent issue ID")
|
||||
.option(
|
||||
"--standalone",
|
||||
"Create a top-level issue. Without --parent-id, an agent run's issue otherwise becomes the parent.",
|
||||
)
|
||||
.option("--request-depth <n>", "Request depth integer")
|
||||
.option("--billing-code <code>", "Billing code")
|
||||
.action(async (opts: IssueCreateOptions) => {
|
||||
try {
|
||||
if (opts.standalone && opts.parentId) {
|
||||
throw new Error("--standalone cannot be combined with --parent-id");
|
||||
}
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const payload = createIssueSchema.parse({
|
||||
title: opts.title,
|
||||
@@ -299,7 +307,7 @@ export function registerIssueCommands(program: Command): void {
|
||||
assigneeAgentId: opts.assigneeAgentId,
|
||||
projectId: opts.projectId,
|
||||
goalId: opts.goalId,
|
||||
parentId: opts.parentId,
|
||||
parentId: opts.standalone ? null : opts.parentId,
|
||||
requestDepth: parseOptionalInt(opts.requestDepth),
|
||||
billingCode: opts.billingCode,
|
||||
});
|
||||
|
||||
+3
-1
@@ -424,7 +424,7 @@ Notes:
|
||||
```sh
|
||||
npx paperclipai issue list --company-id <company-id> [--status todo,in_progress] [--assignee-agent-id <agent-id>] [--match text]
|
||||
npx paperclipai issue get <issue-id-or-identifier>
|
||||
npx paperclipai issue create --company-id <company-id> --title "..." [--description "..."] [--status todo] [--priority high]
|
||||
npx paperclipai issue create --company-id <company-id> --title "..." [--description "..."] [--status todo] [--priority high] [--parent-id <issue-id> | --standalone]
|
||||
npx paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
|
||||
npx paperclipai issue delete <issue-id> --yes
|
||||
npx paperclipai issue comment <issue-id> --body "..." [--attachment-id <id...>] [--reopen]
|
||||
@@ -440,6 +440,8 @@ npx paperclipai issue release <issue-id>
|
||||
npx paperclipai issue force-release <issue-id>
|
||||
```
|
||||
|
||||
`issue create` sent from an agent run bound to an ordinary execution task is parented under that task when `--parent-id` is omitted; pass `--standalone` to create intentionally top-level work (`parentId: null`). The two flags are mutually exclusive.
|
||||
|
||||
Issue subresources are exposed as Paperclip API wrappers. Commands that map to broad server schemas accept JSON payloads and validate them with shared schemas before sending.
|
||||
|
||||
```sh
|
||||
|
||||
@@ -277,6 +277,8 @@ retain their titles and default to no generation request.
|
||||
- identifier fields: `issue_number`, `identifier`
|
||||
- origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint`
|
||||
- Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` selects tasks created by runs bound to that source task, using native run issue identity or persisted legacy task context. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped.
|
||||
- Delegated follow-ups keep their structural parent. When an agent run bound to an ordinary execution task creates an issue and omits `parentId`, the server defaults `parentId` to that run's task and records `parentDefaultedFromRunIssue: true` on the `issue.created` activity. An explicit `parentId: null` keeps the task standalone. The default never applies to conversation runs (chat handoffs stay top-level), to watchdog follow-ups, to board/user creates, to a run task the agent may not mutate, when the default parent would create a delegation cycle with the requested assignee, or when the agent may not create a child under the default parent (for example a protected assignment policy without a grant); those requests stay standalone instead of failing. An explicitly requested parent is still subject to the ordinary assignment check. Structural parentage, creation provenance and blockers stay distinct: the default adds no blocker.
|
||||
- `GET /api/issues/:id` returns `createdFrom` (`{ issue: { id, identifier, title, status }, run: { id, agentId }, agent: { id, name } | null } | null`): the task the origin run was executing when it created this issue, resolved from `origin_run_id` or, for historical rows, the run on the recorded creation activity. It is present whether or not `parentId` is set, and only when the viewer may read the source task and its run; a private, hidden-from-viewer or cross-company source yields `null`, indistinguishable from missing provenance.
|
||||
- Relation lists can use `sortField=id&sortDir=asc&afterId=<uuid>` for stable pagination. The cursor excludes earlier IDs and cannot be combined with an offset or activity-based order.
|
||||
- The streamlined task page's Tasks tab keeps two independent memberships: the existing subtask tree, and created tasks grouped by their current project (or No project). A created subtask appears in both. Only Subtasks has completion progress; groups collapse independently and unfinished tasks sort above finished tasks.
|
||||
- `request_depth` int not null default 0
|
||||
|
||||
@@ -219,3 +219,28 @@ plugin RPC. Its intent pins the registered built-in provider and its receipt say
|
||||
`builtin.stopLease`. This provider owns no real process or filesystem; the receipt
|
||||
models its lifecycle. Missing built-in stop support or an unconfirmed receipt
|
||||
also remains pending through restart, without calling release or destroy.
|
||||
|
||||
### Legacy copy-back after a confirmed stop
|
||||
|
||||
A legacy adapter can report failed workspace copy-back after cancellation has
|
||||
already stopped and released its sandbox lease. The host records the exact lease
|
||||
snapshot from before adapter dispatch. For an originally ephemeral source, the
|
||||
recorder can preserve the released allocation for repair only while the current
|
||||
lease is still ephemeral and its company, run, issue, workspace, provider,
|
||||
allocation, acquisition time, plugin, and realized root still match. It requires
|
||||
both the dedicated stop-and-retain receipt and the matching stopped-execution
|
||||
receipt. A competing owner or changed source fails closed. The recorder leaves
|
||||
release status and receipts intact, excludes the allocation from reuse, and
|
||||
creates the existing board-owned file-repair obligation without replaying work
|
||||
or changing a newer run or the task's status.
|
||||
|
||||
This late-recording path does not admit reusable leases. Provider resume can
|
||||
precede the database handoff, so an old stop receipt and a database row lock
|
||||
cannot prove that reuse has not started. Existing active-source and previously
|
||||
recorded recovery paths retain their behavior. A missing or rejected original
|
||||
snapshot does not authorize adopting another released lease.
|
||||
|
||||
A retained source receipt records control-plane evidence; it does not prove the
|
||||
provider still holds the original bytes or that copy-back succeeded. Operators
|
||||
must verify and recover the exact source separately. Later successful runs and
|
||||
completed tasks do not certify recovery of earlier workspace files.
|
||||
+10
-3
@@ -48,8 +48,9 @@ There are no custom attributes, identifiers, content, or exception messages.
|
||||
The closed phase names in `server/src/services/issue-read-timing.ts` are:
|
||||
`lookup`, `authorization`, `project_goal`, `ancestors`, `mentions`, `documents`,
|
||||
`relations`, `blockers`, `review`, `references`, `handoff`, `retry`, `recovery`,
|
||||
`cases`, `inbox`, `channel`, `workspace`, `work_products`, `execution_blocker`,
|
||||
`relation_recovery`, `revalidate_recovery`, and `mentioned_projects`.
|
||||
`cases`, `inbox`, `channel`, `workspace`, `work_products`, `created_from`,
|
||||
`execution_blocker`, `relation_recovery`, `revalidate_recovery`, and
|
||||
`mentioned_projects`.
|
||||
Without an OTLP endpoint, spans remain no-ops. This adds no first-party
|
||||
Telemetry events or run-log events.
|
||||
|
||||
@@ -631,7 +632,7 @@ pending. Recent events do not prove useful progress. These fields contain only
|
||||
numbers and a boolean, never tool names, IDs, arguments, or event content, and
|
||||
do not change the execution timeout, cancellation, or recovery policy.
|
||||
|
||||
ACP results retain the adapter's resolved wall-clock timeout as
|
||||
ACP and legacy OpenCode results retain the adapter's resolved wall-clock timeout as
|
||||
`adapterExecutionTimeout` in the instance run result. Finalization uses it for
|
||||
`effectiveTimeoutSec`, `timeoutSource`, and `timeoutConfigured`. Sources are
|
||||
`configured`, `sandbox_default`, or `unlimited`; `timeoutConfigured` identifies an
|
||||
@@ -639,6 +640,12 @@ explicit override, including a negative value that disables the timer. An untouc
|
||||
sandbox value of zero reports the four-hour default, while a local zero reports
|
||||
unlimited. Older adapters without a valid resolution retain the config-based
|
||||
metadata fallback. This does not change timers, Stop acknowledgement, or recovery.
|
||||
Legacy OpenCode reports a generic timeout message because a provider or output
|
||||
observation timeout can occur before the configured execution deadline. The
|
||||
resolved policy describes the configured limit, not proof that its timer fired;
|
||||
`timeoutFired` retains the existing terminal timeout classification. Provider
|
||||
stderr remains in the instance run log and is not used as the timeout exception
|
||||
message. An earlier provider timeout does not establish its upstream cause.
|
||||
|
||||
When settlement records a workspace restore failure, `run_execution` also
|
||||
includes `workspaceRestoreFailure` with one of the shared, path-free codes:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Overview copy control
|
||||
|
||||
These Chromium screenshots render the production `RoutineSectionHeading` and `CopyText` components with the app color tokens. The header and description frame is a preview around that heading. It is not a capture of a signed-in routine page.
|
||||
|
||||
Each theme shows the same heading in two states:
|
||||
|
||||
- View: the saved description is visible, and the header shows Edit routine.
|
||||
- Edit: the description source is visible, and the header shows Cancel editing.
|
||||
|
||||
The copy icon stays next to the Overview title in both states. Desktop and mobile widths were checked. These files are the desktop captures.
|
||||
|
||||
- [View, light](overview-view-light.png)
|
||||
- [Edit, light](overview-edit-light.png)
|
||||
- [View, dark](overview-view-dark.png)
|
||||
- [Edit, dark](overview-edit-dark.png)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -1,6 +1,6 @@
|
||||
import { execFile as execFileCallback, spawn } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rename, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import fs, { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -101,12 +101,10 @@ describe("sandbox callback bridge", () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function publishBridgeRequest(requestPath: string, body: string, encoding: "utf8" = "utf8") {
|
||||
// The queue protocol publishes complete envelopes; live .json writes race
|
||||
// the worker's stat/read size fence and can trigger a recovery response.
|
||||
const temporaryPath = `${requestPath}.tmp`;
|
||||
await writeFile(temporaryPath, body, encoding);
|
||||
await rename(temporaryPath, requestPath);
|
||||
// Match the gateway's atomic publication. A visible, growing .json file can
|
||||
// be stat'ed while empty and exceed the reader's reserved size on the read.
|
||||
function publishBridgeRequest(requestPath: string, body: string): Promise<void> {
|
||||
return createFileSystemSandboxCallbackBridgeQueueClient().writeTextFile(requestPath, body);
|
||||
}
|
||||
|
||||
async function waitForJsonFile(directory: string, timeoutMs = 2_000): Promise<string> {
|
||||
@@ -305,6 +303,80 @@ describe("sandbox callback bridge", () => {
|
||||
|
||||
});
|
||||
|
||||
it("publishes complete fixture envelopes before queue readers can reserve their size", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-publication-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const client = createFileSystemSandboxCallbackBridgeQueueClient();
|
||||
const requestPath = path.join(rootDir, "fixture.json");
|
||||
const payload = JSON.stringify({ id: "fixture", method: "GET", path: "/api/openapi.json", body: "" });
|
||||
const listingsDuringWrite: string[][] = [];
|
||||
const write = fs.writeFile.bind(fs);
|
||||
// Force a poll while the writer has created its file but not filled it.
|
||||
// A direct .json write would become visible and reserve zero bytes here.
|
||||
const spy = vi.spyOn(fs, "writeFile").mockImplementation(async (file, body, options) => {
|
||||
if (typeof file !== "string" || path.dirname(file) !== rootDir) return write(file, body, options);
|
||||
await write(file, "", options);
|
||||
const visible = await client.listJsonFiles(rootDir);
|
||||
listingsDuringWrite.push(visible);
|
||||
const reserved = visible.includes("fixture.json") ? await client.fileSize!(requestPath) : null;
|
||||
await write(file, body, options);
|
||||
if (reserved !== null) await client.readTextFile(requestPath, reserved);
|
||||
});
|
||||
try {
|
||||
await publishBridgeRequest(requestPath, payload);
|
||||
expect(listingsDuringWrite).toEqual([[]]);
|
||||
expect(await client.listJsonFiles(rootDir)).toEqual(["fixture.json"]);
|
||||
const size = await client.fileSize!(requestPath);
|
||||
expect(size).toBe(Buffer.byteLength(payload));
|
||||
await expect(client.readTextFile(requestPath, size)).resolves.toBe(payload);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the bounded reader fail-closed when a visible request grows after its size is reserved", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-growing-request-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const queueDir = path.join(rootDir, "queue");
|
||||
const directories = sandboxCallbackBridgeDirectories(queueDir);
|
||||
const client = createFileSystemSandboxCallbackBridgeQueueClient();
|
||||
const requestPath = path.join(directories.requestsDir, "growing.json");
|
||||
await mkdir(directories.requestsDir, { recursive: true });
|
||||
// Deliberately violate the publication contract to reproduce the old
|
||||
// fixture interleaving, without timing assumptions or a weakened limit.
|
||||
await writeFile(requestPath, "");
|
||||
const payload = JSON.stringify({ id: "growing", method: "GET", path: "/api/openapi.json", body: "" });
|
||||
let reserved: number | undefined;
|
||||
const handleRequest = vi.fn(async () => ({ status: 200, body: "unexpected" }));
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const worker = await startSandboxCallbackBridgeWorker({
|
||||
queueDir, handleRequest,
|
||||
client: { ...client, fileSize: async (file) => {
|
||||
const size = await client.fileSize!(file);
|
||||
if (file === requestPath && reserved === undefined) {
|
||||
reserved = size;
|
||||
await writeFile(requestPath, payload);
|
||||
}
|
||||
return size;
|
||||
} },
|
||||
});
|
||||
cleanupFns.push(() => worker.stop());
|
||||
const responseFile = await waitForJsonFile(directories.responsesDir);
|
||||
const response = JSON.parse(await readFile(path.join(directories.responsesDir, responseFile), "utf8"));
|
||||
expect(reserved).toBe(0);
|
||||
expect(response.status).toBe(503);
|
||||
expect(JSON.parse(response.body)).toEqual({
|
||||
error: "Sandbox callback bridge worker failed: Bridge envelope exceeded the configured size limit.",
|
||||
});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Bridge envelope exceeded the configured size limit."));
|
||||
expect(handleRequest).not.toHaveBeenCalled();
|
||||
await worker.stop();
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("serves schema discovery over the queue and denies schema mutations and lookalikes", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-schema-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
@@ -334,7 +406,10 @@ describe("sandbox callback bridge", () => {
|
||||
id: `schema-${index}`, ...request, query: "", headers: {}, body: "", createdAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
await worker.stop({ drainTimeoutMs: 5_000 });
|
||||
await vi.waitFor(async () => {
|
||||
expect(await createFileSystemSandboxCallbackBridgeQueueClient().listJsonFiles(directories.responsesDir))
|
||||
.toEqual(requests.map((_, index) => `schema-${index}.json`));
|
||||
}, { timeout: 5_000 });
|
||||
for (const [index] of requests.entries()) {
|
||||
const response = JSON.parse(await readFile(path.join(directories.responsesDir, `schema-${index}.json`), "utf8"));
|
||||
expect(response.status).toBe(index === 0 ? 200 : 403);
|
||||
@@ -374,7 +449,6 @@ describe("sandbox callback bridge", () => {
|
||||
body: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await worker.stop({ drainTimeoutMs: 1_000 });
|
||||
@@ -422,7 +496,6 @@ describe("sandbox callback bridge", () => {
|
||||
body: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await publishBridgeRequest(
|
||||
path.posix.join(directories.requestsDir, "req-b.json"),
|
||||
@@ -435,7 +508,6 @@ describe("sandbox callback bridge", () => {
|
||||
body: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await worker.stop({ drainTimeoutMs: 1_000 });
|
||||
@@ -481,7 +553,6 @@ describe("sandbox callback bridge", () => {
|
||||
body: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await publishBridgeRequest(
|
||||
path.posix.join(directories.requestsDir, "req-b.json"),
|
||||
@@ -494,7 +565,6 @@ describe("sandbox callback bridge", () => {
|
||||
body: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Begin the short drain deadline only after the first handler has started.
|
||||
@@ -610,7 +680,6 @@ describe("sandbox callback bridge", () => {
|
||||
headers: {},
|
||||
body: "",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const responseFile = await waitForJsonFile(directories.responsesDir, 10_000);
|
||||
@@ -1001,7 +1070,6 @@ describe("sandbox callback bridge", () => {
|
||||
body: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const queueFullResponse = await fetch(`${bridge.baseUrl}/api/agents/me`, {
|
||||
@@ -1831,7 +1899,7 @@ describe("sandbox callback bridge", () => {
|
||||
const queueDir = path.posix.join(rootDir, "queue");
|
||||
const directories = sandboxCallbackBridgeDirectories(queueDir);
|
||||
await mkdir(directories.requestsDir, { recursive: true });
|
||||
await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-a.json"), bridgeRequestJson("req-a"), "utf8");
|
||||
await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-a.json"), bridgeRequestJson("req-a"));
|
||||
|
||||
const { runtimeSpan, workerErrors } = createWorkerErrorCapture();
|
||||
|
||||
@@ -2880,7 +2948,7 @@ describe("sandbox callback bridge", () => {
|
||||
const queueDir = path.posix.join(rootDir, "queue");
|
||||
const directories = sandboxCallbackBridgeDirectories(queueDir);
|
||||
await mkdir(directories.requestsDir, { recursive: true });
|
||||
await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-w.json"), bridgeRequestJson("req-w"), "utf8");
|
||||
await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-w.json"), bridgeRequestJson("req-w"));
|
||||
|
||||
const base = createFileSystemSandboxCallbackBridgeQueueClient();
|
||||
let listCalls = 0;
|
||||
@@ -2927,7 +2995,7 @@ describe("sandbox callback bridge", () => {
|
||||
const queueDir = path.posix.join(rootDir, "queue");
|
||||
const directories = sandboxCallbackBridgeDirectories(queueDir);
|
||||
await mkdir(directories.requestsDir, { recursive: true });
|
||||
await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-ok.json"), bridgeRequestJson("req-ok"), "utf8");
|
||||
await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-ok.json"), bridgeRequestJson("req-ok"));
|
||||
|
||||
const { runtimeSpan, workerErrors } = createWorkerErrorCapture();
|
||||
const processed: string[] = [];
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
process: vi.fn(), restore: vi.fn(), stop: vi.fn(),
|
||||
}));
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("@paperclipai/adapter-utils/execution-target")>(),
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(),
|
||||
ensureAdapterExecutionTargetCommandResolvable: vi.fn(),
|
||||
prepareAdapterExecutionTargetRuntime: vi.fn(async () => ({
|
||||
runtimeRootDir: "/workspace/runtime", workspaceRemoteDir: "/workspace",
|
||||
assetDirs: {}, restoreWorkspace: mocks.restore,
|
||||
})),
|
||||
readAdapterExecutionTargetHomeDir: vi.fn(async () => "/home/fixture"),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ env: {}, stop: mocks.stop })),
|
||||
runAdapterExecutionTargetProcess: mocks.process,
|
||||
}));
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
const rawOutput = "private-output-canary";
|
||||
const rawError = "<html>504 Gateway Time-out private-provider-token-canary</html>";
|
||||
const failed: RunProcessResult = {
|
||||
timedOut: true, exitCode: null, signal: null, stdout: rawOutput, stderr: rawError,
|
||||
pid: null, startedAt: "2020-01-01T00:00:00.000Z", durationMs: 100,
|
||||
};
|
||||
|
||||
describe("OpenCode timeout reporting", () => {
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-timeout-"));
|
||||
vi.stubEnv("XDG_CONFIG_HOME", dir);
|
||||
mocks.process.mockReset().mockResolvedValue(failed);
|
||||
mocks.restore.mockReset().mockResolvedValue(undefined);
|
||||
mocks.stop.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
const context = (timeoutSec: number, remote = true): AdapterExecutionContext => ({
|
||||
runId: "run-fixture",
|
||||
agent: { id: "agent-fixture", companyId: "company-fixture", name: "Fixture", adapterType: "opencode_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: { command: "opencode", model: "openai/fixture", cwd: dir, timeoutSec,
|
||||
env: { OPENCODE_ALLOW_ALL_MODELS: "1" } },
|
||||
context: {}, onLog: async () => {},
|
||||
...(remote ? { executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" } } : {}),
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, true, 14400, "sandbox_default"], [12, true, 12, "configured"],
|
||||
[-1, true, 0, "configured"], [0, false, 0, "unlimited"], [12, false, 12, "configured"],
|
||||
] as const)("keeps policy separate from an opaque timeout (%s, remote=%s)", async (configured, remote, resolved, source) => {
|
||||
const result = await execute(context(configured, remote));
|
||||
expect(result).toMatchObject({ timedOut: true, exitCode: null, signal: null,
|
||||
errorMessage: "OpenCode execution timed out",
|
||||
resultJson: { adapterExecutionTimeout: { timeoutSec: resolved, source } } });
|
||||
expect(JSON.stringify(result)).not.toContain("private-");
|
||||
expect(result.errorMessage).not.toContain(String(resolved));
|
||||
expect(mocks.process).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.process.mock.calls[0]![4]).toMatchObject({ timeoutSec: resolved, graceSec: 20 });
|
||||
expect(mocks.restore).toHaveBeenCalledTimes(remote ? 1 : 0);
|
||||
expect(mocks.stop).toHaveBeenCalledTimes(remote ? 1 : 0);
|
||||
});
|
||||
|
||||
it.each([0, 7])("retains output and completion semantics for a non-timeout exit %s", async (exitCode) => {
|
||||
mocks.process.mockResolvedValue({ ...failed, timedOut: false, exitCode, stderr: "fixture failure" });
|
||||
const result = await execute(context(0));
|
||||
expect(result).toMatchObject({ timedOut: false, exitCode, signal: null,
|
||||
errorMessage: exitCode === 0 ? null : "fixture failure",
|
||||
resultJson: { stdout: rawOutput, stderr: "fixture failure",
|
||||
adapterExecutionTimeout: { timeoutSec: 14400, source: "sandbox_default" } } });
|
||||
expect(mocks.process).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.restore).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retains real local deadline and termination behavior without claiming provider origin", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>("@paperclipai/adapter-utils/execution-target");
|
||||
mocks.process.mockImplementation(actual.runAdapterExecutionTargetProcess);
|
||||
const command = path.join(dir, "fixture-opencode");
|
||||
await fs.writeFile(command, "#!/usr/bin/env node\nsetInterval(() => {}, 1000);\n", { mode: 0o755 });
|
||||
const ctx = context(0.1, false);
|
||||
ctx.config.command = command;
|
||||
ctx.config.graceSec = 0.1;
|
||||
const result = await execute(ctx);
|
||||
expect(result).toMatchObject({ timedOut: true, exitCode: null, signal: "SIGTERM",
|
||||
errorMessage: "OpenCode execution timed out",
|
||||
resultJson: { adapterExecutionTimeout: { timeoutSec: 0.1, source: "configured" } } });
|
||||
expect(mocks.process).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
adapterExecutionTargetEnablesSandboxDuplexBridge,
|
||||
readAdapterExecutionTarget,
|
||||
readAdapterExecutionTargetHomeDir,
|
||||
resolveAdapterExecutionTargetTimeoutSec,
|
||||
resolveAdapterExecutionTargetTimeout,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
@@ -338,10 +338,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
|
||||
const adapterExecutionTimeout = resolveAdapterExecutionTargetTimeout(
|
||||
executionTarget,
|
||||
asNumber(config.timeoutSec, 0),
|
||||
);
|
||||
const timeoutSec = adapterExecutionTimeout.timeoutSec;
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId,
|
||||
@@ -710,7 +711,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
billingType: "unknown",
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
costStatus: attempt.parsed.usageComplete || attempt.parsed.costUsd != null ? undefined : "unpriced",
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
// A provider or output-observation timeout can arrive before the
|
||||
// configured execution deadline. The boolean does not prove which
|
||||
// timer fired, and provider stderr is not safe exception text.
|
||||
errorMessage: "OpenCode execution timed out",
|
||||
resultJson: { adapterExecutionTimeout },
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
@@ -766,6 +771,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
costStatus: attempt.parsed.usageComplete || attempt.parsed.costUsd != null ? undefined : "unpriced",
|
||||
resultJson: {
|
||||
adapterExecutionTimeout,
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
|
||||
@@ -1245,6 +1245,7 @@ export type {
|
||||
DocumentFormat,
|
||||
LegacyPlanDocument,
|
||||
IssueAttachment,
|
||||
IssueCreationSource,
|
||||
IssueLabel,
|
||||
IssueTreeControlPreview,
|
||||
IssueTreeHold,
|
||||
|
||||
@@ -804,6 +804,7 @@ export type {
|
||||
IssueAncestorProject,
|
||||
IssueAncestorGoal,
|
||||
IssueAttachment,
|
||||
IssueCreationSource,
|
||||
IssueLabel,
|
||||
IssueWatchdog,
|
||||
IssueWatchdogStatus,
|
||||
|
||||
@@ -129,6 +129,22 @@ export interface IssueLabel {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The task a run was executing when it created this issue, from recorded run
|
||||
* provenance. Independent of the structural parent and of blockers. Only
|
||||
* present when the viewer may read the source task and its run.
|
||||
*/
|
||||
export interface IssueCreationSource {
|
||||
issue: {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
};
|
||||
run: { id: string; agentId: string };
|
||||
agent: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
export interface IssueAssigneeAdapterOverrides {
|
||||
adapterConfig?: Record<string, unknown>;
|
||||
useProjectWorkspace?: boolean;
|
||||
@@ -840,6 +856,8 @@ export interface Issue {
|
||||
/** Immediate task from which private access flows downward, including chat handoffs. */
|
||||
privacyParentIssueId?: string | null;
|
||||
ancestors?: IssueAncestor[];
|
||||
/** Detail responses only: access-checked creation provenance, or null. */
|
||||
createdFrom?: IssueCreationSource | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
descriptionTruncated?: boolean;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
|
||||
describePostgres("agent-created follow-ups: parent links and creation source", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
const companyId = randomUUID();
|
||||
const otherCompanyId = randomUUID();
|
||||
const qaAgentId = randomUUID();
|
||||
const engineerAgentId = randomUUID();
|
||||
const foreignAgentId = randomUUID();
|
||||
const sourceId = randomUUID();
|
||||
const conversationId = randomUUID();
|
||||
const privateSourceId = randomUUID();
|
||||
const protectedSourceId = randomUUID();
|
||||
const foreignSourceId = randomUUID();
|
||||
const runOnSource = randomUUID();
|
||||
const runOnConversation = randomUUID();
|
||||
const runOnPrivate = randomUUID();
|
||||
const runOnProtected = randomUUID();
|
||||
const foreignRun = randomUUID();
|
||||
const legacyRun = randomUUID();
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-creation-source-");
|
||||
db = createDb(database.connectionString);
|
||||
await db.insert(companies).values([
|
||||
{ id: companyId, name: "Paperclip", issuePrefix: "PAP", defaultResponsibleUserId: "board-user", requireBoardApprovalForNewAgents: false },
|
||||
{ id: otherCompanyId, name: "Other", issuePrefix: "OTH", defaultResponsibleUserId: "board-user", requireBoardApprovalForNewAgents: false },
|
||||
]);
|
||||
await db.insert(agents).values([
|
||||
{ id: qaAgentId, companyId, name: "Paperclip QA", role: "qa", adapterType: "codex_local", status: "active" },
|
||||
{ id: engineerAgentId, companyId, name: "Engineer", role: "engineer", adapterType: "codex_local", status: "active" },
|
||||
{ id: foreignAgentId, companyId: otherCompanyId, name: "Other QA", role: "qa", adapterType: "codex_local", status: "active" },
|
||||
]);
|
||||
await db.insert(issues).values([
|
||||
{ id: sourceId, companyId, title: "Verify Tailscale fix", identifier: "PAP-168", issueNumber: 168, status: "in_progress", assigneeAgentId: qaAgentId, createdByAgentId: engineerAgentId },
|
||||
{ id: conversationId, companyId, title: "Chat with QA", identifier: "PAP-170", issueNumber: 170, status: "in_review", assigneeAgentId: qaAgentId, conversationAgentId: qaAgentId, conversationUserId: "board-user", conversationState: "waiting" },
|
||||
{ id: privateSourceId, companyId, title: "Private finding", identifier: "PAP-171", issueNumber: 171, status: "in_progress", visibility: "private", privacyRootIssueId: privateSourceId, assigneeAgentId: qaAgentId },
|
||||
{ id: protectedSourceId, companyId, title: "Protected task", identifier: "PAP-172", issueNumber: 172, status: "in_progress", assigneeAgentId: qaAgentId, executionPolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } },
|
||||
{ id: foreignSourceId, companyId: otherCompanyId, title: "Foreign source", identifier: "OTH-1", issueNumber: 1 },
|
||||
]);
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{ id: runOnSource, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: sourceId, nativeIssueId: sourceId, status: "running", contextSnapshot: { issueId: sourceId } },
|
||||
{ id: runOnConversation, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: conversationId, status: "running", contextSnapshot: { issueId: conversationId } },
|
||||
{ id: runOnPrivate, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: privateSourceId, status: "running", contextSnapshot: { issueId: privateSourceId } },
|
||||
{ id: runOnProtected, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: protectedSourceId, status: "running", contextSnapshot: { issueId: protectedSourceId } },
|
||||
{ id: foreignRun, companyId: otherCompanyId, agentId: foreignAgentId, scopeKind: "issue", issueId: foreignSourceId, status: "running" },
|
||||
{ id: legacyRun, companyId, agentId: qaAgentId, status: "succeeded", contextSnapshot: { taskKey: "PAP-168" } },
|
||||
]);
|
||||
}, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
|
||||
const boardActor = { type: "board", userId: "board-user", source: "local_implicit", isInstanceAdmin: true } as Express.Request["actor"];
|
||||
function agentActor(agentId: string, runId?: string): Express.Request["actor"] {
|
||||
return { type: "agent", companyId, agentId, runId, source: "agent_jwt", onBehalfOfUserId: null, onBehalfOfMemberships: [] } as Express.Request["actor"];
|
||||
}
|
||||
function app(actor: Express.Request["actor"]) {
|
||||
const instance = express();
|
||||
instance.use(express.json());
|
||||
instance.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
instance.use("/api", issueRoutes(db, {} as never));
|
||||
instance.use(errorHandler);
|
||||
return instance;
|
||||
}
|
||||
async function createdActivity(issueId: string) {
|
||||
const [row] = await db.select().from(activityLog)
|
||||
.where(and(eq(activityLog.entityId, issueId), eq(activityLog.action, "issue.created")));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
it("parents an ordinary delegated follow-up under the run's task when parentId is omitted", async () => {
|
||||
const created = await request(app(agentActor(qaAgentId, runOnSource)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Fix regression found during verification" });
|
||||
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
||||
expect(created.body.parentId).toBe(sourceId);
|
||||
expect(created.body.originRunId).toBe(runOnSource);
|
||||
expect(created.body.blockedBy ?? []).toEqual([]);
|
||||
const activity = await createdActivity(created.body.id);
|
||||
expect(activity?.details).toMatchObject({ parentId: sourceId, parentDefaultedFromRunIssue: true });
|
||||
|
||||
const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.createdFrom).toEqual({
|
||||
issue: { id: sourceId, identifier: "PAP-168", title: "Verify Tailscale fix", status: "in_progress" },
|
||||
run: { id: runOnSource, agentId: qaAgentId },
|
||||
agent: { id: qaAgentId, name: "Paperclip QA" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an explicit parentId: null standalone but still exposes the source task", async () => {
|
||||
const created = await request(app(agentActor(qaAgentId, runOnSource)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Intentional top-level follow-up", parentId: null });
|
||||
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
||||
expect(created.body.parentId).toBeNull();
|
||||
const activity = await createdActivity(created.body.id);
|
||||
expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue");
|
||||
|
||||
const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.parentId).toBeNull();
|
||||
expect(detail.body.createdFrom).toMatchObject({
|
||||
issue: { id: sourceId, identifier: "PAP-168" },
|
||||
agent: { name: "Paperclip QA" },
|
||||
});
|
||||
});
|
||||
|
||||
it("never parents conversation handoffs or board-created tasks", async () => {
|
||||
const handoff = await request(app(agentActor(qaAgentId, runOnConversation)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Execution task from chat" });
|
||||
expect(handoff.status, JSON.stringify(handoff.body)).toBe(201);
|
||||
expect(handoff.body.parentId).toBeNull();
|
||||
|
||||
const manual = await request(app(boardActor))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Board task" });
|
||||
expect(manual.status, JSON.stringify(manual.body)).toBe(201);
|
||||
expect(manual.body.parentId).toBeNull();
|
||||
const detail = await request(app(boardActor)).get(`/api/issues/${manual.body.id}`);
|
||||
expect(detail.body.createdFrom).toBeNull();
|
||||
});
|
||||
|
||||
it("stays standalone instead of failing when the default parent would be a delegation cycle", async () => {
|
||||
const created = await request(app(agentActor(qaAgentId, runOnSource)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Bug for the engineer who delegated to QA", assigneeAgentId: engineerAgentId });
|
||||
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
||||
expect(created.body.parentId).toBeNull();
|
||||
expect(created.body.assigneeAgentId).toBe(engineerAgentId);
|
||||
const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`);
|
||||
expect(detail.body.createdFrom).toMatchObject({ issue: { id: sourceId } });
|
||||
});
|
||||
|
||||
it("stays standalone instead of failing when the default parent is protected without a grant", async () => {
|
||||
// The agent runs the protected task, so it may mutate it, but a protected
|
||||
// assignment policy denies child creation without an explicit grant.
|
||||
const created = await request(app(agentActor(qaAgentId, runOnProtected)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Follow-up from a protected task" });
|
||||
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
||||
expect(created.body.parentId).toBeNull();
|
||||
const activity = await createdActivity(created.body.id);
|
||||
expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue");
|
||||
const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`);
|
||||
expect(detail.body.createdFrom).toMatchObject({ issue: { id: protectedSourceId, identifier: "PAP-172" } });
|
||||
|
||||
const explicit = await request(app(agentActor(qaAgentId, runOnProtected)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Explicit child of a protected task", parentId: protectedSourceId });
|
||||
expect(explicit.status, JSON.stringify(explicit.body)).toBe(403);
|
||||
});
|
||||
|
||||
it("still honors an explicit parent and rejects an explicit delegation cycle", async () => {
|
||||
const [other] = await db.insert(issues).values({ companyId, title: "Another parent", status: "todo" }).returning();
|
||||
const created = await request(app(agentActor(qaAgentId, runOnSource)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Explicit child", parentId: other!.id });
|
||||
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
||||
expect(created.body.parentId).toBe(other!.id);
|
||||
const activity = await createdActivity(created.body.id);
|
||||
expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue");
|
||||
|
||||
const cycle = await request(app(agentActor(qaAgentId, runOnSource)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Explicit cycle", parentId: sourceId, assigneeAgentId: engineerAgentId });
|
||||
expect(cycle.status).toBe(409);
|
||||
});
|
||||
|
||||
it("resolves historical provenance from the creation activity's run", async () => {
|
||||
const historicalId = randomUUID();
|
||||
await db.insert(issues).values({ id: historicalId, companyId, title: "Historical follow-up", status: "todo", createdByAgentId: qaAgentId });
|
||||
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: qaAgentId, runId: legacyRun, action: "issue.created", entityType: "issue", entityId: historicalId });
|
||||
const detail = await request(app(boardActor)).get(`/api/issues/${historicalId}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.createdFrom).toMatchObject({ issue: { id: sourceId, identifier: "PAP-168" }, run: { id: legacyRun } });
|
||||
});
|
||||
|
||||
it("returns null when provenance is missing, inaccessible, or from another company", async () => {
|
||||
const orphanId = randomUUID();
|
||||
const fromPrivateId = randomUUID();
|
||||
const fromForeignId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{ id: orphanId, companyId, title: "No provenance", status: "todo", createdByAgentId: qaAgentId },
|
||||
{ id: fromPrivateId, companyId, title: "Created from a private task", status: "todo", originRunId: runOnPrivate, createdByAgentId: qaAgentId },
|
||||
{ id: fromForeignId, companyId, title: "Origin run in another company", status: "todo", originRunId: foreignRun },
|
||||
]);
|
||||
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: qaAgentId, runId: null, action: "issue.created", entityType: "issue", entityId: orphanId });
|
||||
|
||||
const orphan = await request(app(boardActor)).get(`/api/issues/${orphanId}`);
|
||||
expect(orphan.body.createdFrom).toBeNull();
|
||||
|
||||
const foreign = await request(app(boardActor)).get(`/api/issues/${fromForeignId}`);
|
||||
expect(foreign.status).toBe(200);
|
||||
expect(foreign.body.createdFrom).toBeNull();
|
||||
|
||||
const asOutsider = await request(app(agentActor(engineerAgentId))).get(`/api/issues/${fromPrivateId}`);
|
||||
expect(asOutsider.status, JSON.stringify(asOutsider.body)).toBe(200);
|
||||
expect(asOutsider.body.createdFrom).toBeNull();
|
||||
|
||||
const asParticipant = await request(app(agentActor(qaAgentId))).get(`/api/issues/${fromPrivateId}`);
|
||||
expect(asParticipant.status).toBe(200);
|
||||
expect(asParticipant.body.createdFrom).toMatchObject({ issue: { id: privateSourceId, identifier: "PAP-171" } });
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, environmentLeases, environments, heartbeatRuns, issueRecoveryActions, issues } from "@paperclipai/db";
|
||||
import { agents, companies, createDb, environmentLeases, environments, executionWorkspaces, heartbeatRuns, issueRecoveryActions, issues, projects } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { recordLegacyWorkspaceRestoreFailure, legacyExecutionNeedsReconciliationWithEvidence, terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
|
||||
import { hasConversationContinuationPolicy, conversationRecoveryActionPredicate } from "../services/conversation-continuation.js";
|
||||
import { preserveWorkspaceRestoreRecoveryMetadataSql } from "../services/legacy-workspace-restore-recovery.js";
|
||||
import { settleStopOnlyCleanup } from "../services/sandbox-stop-and-retain.js";
|
||||
import { hasConfirmedSandboxStopAndRetain, prepareSandboxStopAndRetain, settleStopOnlyCleanup } from "../services/sandbox-stop-and-retain.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { hasRequiredWorkspaceRecovery } from "../services/workspace-restore-recovery-state.js";
|
||||
|
||||
const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim();
|
||||
@@ -22,14 +23,20 @@ const support = externalTestDatabaseUrl ? { supported: true } : await getEmbedde
|
||||
await db.insert(companies).values({ id: companyId, name: "Workspace recovery", issuePrefix: companyId.slice(0, 8) });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Engineer", role: "engineer", adapterType: "codex_local" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Restore files", status: options.taskDone ? "done" : "in_progress", assigneeAgentId: agentId });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "legacy", contextSnapshot: { issueId } }).returning();
|
||||
const [project] = await db.insert(projects).values({ companyId, name: "Workspace project" }).returning();
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, projectId: project.id,
|
||||
sourceIssueId: issueId, mode: "isolated", strategyType: "git_worktree", name: "Fixture workspace", cwd: "/work/fixture",
|
||||
}).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, status: "running", runtimeMode: "legacy",
|
||||
contextSnapshot: { issueId, executionWorkspaceId: workspace.id },
|
||||
}).returning();
|
||||
if (options.local) await db.insert(environments).values({ name: "Local", driver: "local" }).onConflictDoNothing();
|
||||
const [environment] = options.local ? await db.select().from(environments).where(eq(environments.driver, "local"))
|
||||
: await db.insert(environments).values({ name: `Isolated test sandbox ${runId}`, driver: "sandbox" }).returning();
|
||||
const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: runId, environmentId: environment.id,
|
||||
const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, executionWorkspaceId: workspace.id, heartbeatRunId: runId, environmentId: environment.id,
|
||||
status: "active", leasePolicy: options.reusable ? "reuse_by_environment" : "ephemeral",
|
||||
provider: options.local ? "local" : "daytona", providerLeaseId: randomUUID(),
|
||||
metadata: { driver: options.local ? "local" : "sandbox", pluginId: randomUUID(), sandboxProviderPlugin: !options.local },
|
||||
metadata: { remoteCwd: "/work/fixture", workspaceRealization: { authoritativeRoot: "/work/fixture" }, driver: options.local ? "local" : "sandbox", pluginId: randomUUID(), sandboxProviderPlugin: !options.local },
|
||||
}).returning();
|
||||
return { companyId, runId, issueId, run, lease };
|
||||
}
|
||||
@@ -38,6 +45,22 @@ const support = externalTestDatabaseUrl ? { supported: true } : await getEmbedde
|
||||
} };
|
||||
const readLease = async (id: string) => (await db.select().from(environmentLeases).where(eq(environmentLeases.id, id)))[0];
|
||||
|
||||
async function stopAndRetain(f: Awaited<ReturnType<typeof seed>>, retry = false) {
|
||||
let stopping = (await prepareSandboxStopAndRetain(db, f.lease))!;
|
||||
expect(stopping.status).toBe("pending_cleanup");
|
||||
if (retry) {
|
||||
[stopping] = await db.update(environmentLeases).set({ metadata: {
|
||||
...stopping.metadata, pendingCleanupAttemptId: randomUUID(),
|
||||
} }).where(eq(environmentLeases.id, f.lease.id)).returning();
|
||||
}
|
||||
const stopped = (await settleStopOnlyCleanup(db, stopping, {
|
||||
attemptId: String(stopping.metadata!.pendingCleanupAttemptId),
|
||||
receipt: { providerLeaseId: f.lease.providerLeaseId, state: "stopped" },
|
||||
}))!;
|
||||
expect(stopped.status).toBe("released");
|
||||
return stopped;
|
||||
}
|
||||
|
||||
it.each([false, true])("persists terminal status, stop-only source and board action atomically (task done=%s)", async taskDone => {
|
||||
const f = await seed({ taskDone, reusable: true });
|
||||
expect(await legacyExecutionNeedsReconciliationWithEvidence(db, { ...f.run, status: "failed", ...patch })).toBe(true);
|
||||
@@ -69,6 +92,239 @@ const support = externalTestDatabaseUrl ? { supported: true } : await getEmbedde
|
||||
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("records the exact stopped source when provider cancellation finishes before copy-back", async () => {
|
||||
const f = await seed({ taskDone: true });
|
||||
const stopping = await prepareSandboxStopAndRetain(db, f.lease);
|
||||
expect(stopping).not.toBeNull();
|
||||
const stopped = await settleStopOnlyCleanup(db, stopping!, {
|
||||
attemptId: String(stopping!.metadata?.pendingCleanupAttemptId),
|
||||
receipt: { providerLeaseId: f.lease.providerLeaseId, state: "stopped" },
|
||||
});
|
||||
expect(stopped?.status).toBe("released");
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date(),
|
||||
resultJson: { executionCancellation: { state: "acknowledged" } },
|
||||
}).where(eq(heartbeatRuns.id, f.runId));
|
||||
// This is the lease captured by the host before invoking the adapter.
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease);
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
expect(run.status).toBe("cancelled");
|
||||
expect(run.resultJson?.executionCancellation).toEqual({ state: "acknowledged" });
|
||||
expect(run.resultJson?.workspaceRestoreRecovery).toMatchObject({ leaseIds: [f.lease.id] });
|
||||
const retained = await readLease(f.lease.id);
|
||||
expect(retained).toMatchObject({ status: "released", leasePolicy: "retain_on_failure", cleanupStatus: "success",
|
||||
releasedAt: stopped!.releasedAt, metadata: { remoteExecutionTermination: stopped!.metadata!.remoteExecutionTermination,
|
||||
sandboxStopAndRetainReceipt: stopped!.metadata!.sandboxStopAndRetainReceipt } });
|
||||
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
|
||||
expect(action).toMatchObject({ status: "resolved", outcome: "blocked", ownerType: "board",
|
||||
evidence: { automaticRecovery: { replay: "blocked" }, workspaceRestoreRecovery: { leaseIds: [f.lease.id] } } });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("done");
|
||||
// A lost commit response can cause the exact adapter receipt to be recorded twice.
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease);
|
||||
const repeated = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
|
||||
expect(repeated).toHaveLength(1);
|
||||
expect(repeated[0]).toMatchObject({ id: action.id, status: "resolved", outcome: "blocked", attemptCount: 1,
|
||||
evidence: { automaticRecovery: { replay: "blocked" }, workspaceRestoreRecovery: { leaseIds: [f.lease.id] } } });
|
||||
expect(await readLease(f.lease.id)).toEqual(retained);
|
||||
});
|
||||
|
||||
it.each(["retry", "builtin"])("keeps the original stop proof after %s dispatch", async scenario => {
|
||||
const f = await seed();
|
||||
if (scenario === "builtin") {
|
||||
[f.lease] = await db.update(environmentLeases).set({ provider: "fake", metadata: {
|
||||
driver: "sandbox", remoteCwd: "/work/fixture",
|
||||
} }).where(eq(environmentLeases.id, f.lease.id)).returning();
|
||||
}
|
||||
const stopped = await stopAndRetain(f, scenario === "retry");
|
||||
if (scenario === "retry") expect((stopped.metadata!.sandboxStopAndRetainReceipt as Record<string, unknown>).requestId)
|
||||
.not.toBe(stopped.metadata!.pendingCleanupAttemptId);
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease);
|
||||
expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure", status: "released",
|
||||
metadata: { sandboxStopAndRetainReceipt: stopped.metadata!.sandboxStopAndRetainReceipt } });
|
||||
});
|
||||
|
||||
it.each(["id", "companyId", "heartbeatRunId", "environmentId", "issueId", "executionWorkspaceId", "provider", "providerLeaseId", "acquiredAt", "pluginId", "remoteCwd", "workspaceRealization"])(
|
||||
"rejects a stopped source with a different original %s", async field => {
|
||||
const f = await seed();
|
||||
const stopped = await stopAndRetain(f);
|
||||
const source = { ...f.lease, metadata: { ...f.lease.metadata } };
|
||||
if (["pluginId", "remoteCwd", "workspaceRealization"].includes(field)) source.metadata[field] = "different-original-value";
|
||||
else if (field === "acquiredAt") source.acquiredAt = new Date(source.acquiredAt.getTime() - 1);
|
||||
else Object.assign(source, { [field]: randomUUID() });
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, source);
|
||||
expect(await readLease(f.lease.id)).toEqual(stopped);
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["missing_dedicated", "missing_termination", "destroyed", "foreign_receipt", "wrong_plugin", "wrong_method", "empty_request", "invalid_time", "before_acquisition", "after_release", "unequal_confirmation", "pending_intent", "cleanup_in_flight", "expired_allocation", "missing_release_time", "retained_without_stop", "failed_cleanup"])(
|
||||
"does not infer retention from %s", async scenario => {
|
||||
const f = await seed();
|
||||
const stopped = await stopAndRetain(f);
|
||||
const metadata = structuredClone(stopped.metadata!);
|
||||
const dedicated = metadata.sandboxStopAndRetainReceipt as Record<string, unknown>;
|
||||
const termination = metadata.remoteExecutionTermination as Record<string, unknown>;
|
||||
if (scenario === "missing_dedicated") delete metadata.sandboxStopAndRetainReceipt;
|
||||
if (scenario === "missing_termination") delete metadata.remoteExecutionTermination;
|
||||
if (scenario === "destroyed") termination.state = "destroyed";
|
||||
if (scenario === "foreign_receipt") dedicated.runId = randomUUID();
|
||||
if (scenario === "wrong_plugin") dedicated.pluginId = randomUUID();
|
||||
if (scenario === "wrong_method") dedicated.method = "environmentReleaseLease";
|
||||
if (scenario === "empty_request") dedicated.requestId = " ";
|
||||
if (scenario === "invalid_time") dedicated.confirmedAt = termination.confirmedAt = "not-a-time";
|
||||
if (scenario === "before_acquisition") dedicated.confirmedAt = termination.confirmedAt = new Date(stopped.acquiredAt.getTime() - 1).toISOString();
|
||||
if (scenario === "after_release") dedicated.confirmedAt = termination.confirmedAt = new Date(stopped.releasedAt!.getTime() + 1).toISOString();
|
||||
if (scenario === "unequal_confirmation") dedicated.confirmedAt = new Date(stopped.releasedAt!.getTime() - 1).toISOString();
|
||||
if (scenario === "pending_intent") metadata.sandboxStopAndRetain = {};
|
||||
if (scenario === "cleanup_in_flight") metadata.pendingCleanupInFlight = true;
|
||||
const [invalid] = await db.update(environmentLeases).set({ metadata,
|
||||
...(scenario === "retained_without_stop" ? { status: "retained" } : {}),
|
||||
...(scenario === "expired_allocation" ? { expiresAt: new Date(stopped.acquiredAt.getTime() - 1) } : {}),
|
||||
...(scenario === "missing_release_time" ? { releasedAt: null } : {}),
|
||||
...(scenario === "failed_cleanup" ? { cleanupStatus: "failed" } : {}),
|
||||
}).where(eq(environmentLeases.id, f.lease.id)).returning();
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease);
|
||||
expect(await readLease(f.lease.id)).toEqual(invalid);
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects invalid lease timestamps instead of treating receipt metadata as proof", async () => {
|
||||
const f = await seed();
|
||||
const stopped = await stopAndRetain(f);
|
||||
expect(hasConfirmedSandboxStopAndRetain(stopped)).toBe(true);
|
||||
for (const field of ["acquiredAt", "releasedAt", "expiresAt"] as const) {
|
||||
expect(hasConfirmedSandboxStopAndRetain({ ...stopped, [field]: new Date(Number.NaN) })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["missing_root", "empty_root", "invalid_realized_root", "run_workspace_mismatch"])(
|
||||
"requires the original execution root after %s", async scenario => {
|
||||
const f = await seed();
|
||||
const metadata = { ...f.lease.metadata };
|
||||
if (scenario === "missing_root") delete metadata.remoteCwd;
|
||||
if (scenario === "empty_root") metadata.remoteCwd = " ";
|
||||
if (scenario === "invalid_realized_root") metadata.workspaceRealization = { authoritativeRoot: " " };
|
||||
[f.lease] = await db.update(environmentLeases).set({ metadata }).where(eq(environmentLeases.id, f.lease.id)).returning();
|
||||
if (scenario === "run_workspace_mismatch") await db.update(heartbeatRuns).set({ contextSnapshot: {
|
||||
...f.run.contextSnapshot, executionWorkspaceId: randomUUID(),
|
||||
} }).where(eq(heartbeatRuns.id, f.runId));
|
||||
const stopped = await stopAndRetain(f);
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease);
|
||||
expect(await readLease(f.lease.id)).toEqual(stopped);
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not search arbitrary stopped leases when the original host snapshot is missing", async () => {
|
||||
const f = await seed();
|
||||
const stopped = await stopAndRetain(f);
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson);
|
||||
expect(await readLease(f.lease.id)).toEqual(stopped);
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["active", "stopped", "older_stopped", "older_destroyed"])("rejects a competing %s allocation owner across companies", async state => {
|
||||
const f = await seed(), other = await seed();
|
||||
const stopped = await stopAndRetain(f);
|
||||
[other.lease] = await db.update(environmentLeases).set({ providerLeaseId: f.lease.providerLeaseId,
|
||||
acquiredAt: new Date(f.lease.acquiredAt.getTime() + (state.startsWith("older_") ? -1_000 : 1)),
|
||||
}).where(eq(environmentLeases.id, other.lease.id)).returning();
|
||||
if (state !== "active") {
|
||||
const stoppedOther = await stopAndRetain(other);
|
||||
if (state === "older_destroyed") await db.update(environmentLeases).set({ metadata: {
|
||||
...stoppedOther.metadata, remoteExecutionTermination: {
|
||||
...(stoppedOther.metadata!.remoteExecutionTermination as Record<string, unknown>), state: "destroyed",
|
||||
},
|
||||
} }).where(eq(environmentLeases.id, other.lease.id));
|
||||
}
|
||||
const competing = await readLease(other.lease.id);
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease);
|
||||
expect(await readLease(f.lease.id)).toEqual(stopped);
|
||||
expect(await readLease(other.lease.id)).toEqual(competing);
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not claim a released reusable source from a stop receipt that may precede resume", async () => {
|
||||
const f = await seed({ reusable: true });
|
||||
const stopped = await stopAndRetain(f);
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease);
|
||||
expect(await readLease(f.lease.id)).toEqual(stopped);
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["original_reusable", "current_reusable", "expired", "root_rebound", "run_rebound"])(
|
||||
"leaves a stopped source unchanged after %s", async scenario => {
|
||||
const f = await seed({ reusable: scenario === "original_reusable" });
|
||||
const other = await seed();
|
||||
const stopped = await stopAndRetain(f);
|
||||
const [changed] = await db.update(environmentLeases).set({
|
||||
...(scenario === "original_reusable" ? { leasePolicy: "ephemeral" } : {}),
|
||||
...(scenario === "current_reusable" ? { leasePolicy: "reuse_by_environment" } : {}),
|
||||
...(scenario === "expired" ? { status: "expired" } : {}),
|
||||
...(scenario === "root_rebound" ? { metadata: { ...stopped.metadata, remoteCwd: "/work/replacement" } } : {}),
|
||||
...(scenario === "run_rebound" ? { heartbeatRunId: other.runId } : {}),
|
||||
}).where(eq(environmentLeases.id, f.lease.id)).returning();
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease);
|
||||
expect(await readLease(f.lease.id)).toEqual(changed);
|
||||
expect(await readLease(other.lease.id)).toEqual(other.lease);
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not adopt the expired source when a normal reusable handoff wins first", async () => {
|
||||
const f = await seed({ reusable: true });
|
||||
await stopAndRetain(f);
|
||||
const newerRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: newerRunId, companyId: f.companyId, agentId: f.run.agentId,
|
||||
status: "running", runtimeMode: "legacy", contextSnapshot: { issueId: f.issueId } });
|
||||
// The production handoff also checks the immutable task/agent scope.
|
||||
[f.lease] = await db.update(environmentLeases).set({ metadata: {
|
||||
...(await readLease(f.lease.id)).metadata, agentId: f.run.agentId,
|
||||
} }).where(eq(environmentLeases.id, f.lease.id)).returning();
|
||||
const newer = await environmentService(db).acquireLease({ companyId: f.companyId,
|
||||
environmentId: f.lease.environmentId!, issueId: f.issueId, executionWorkspaceId: f.lease.executionWorkspaceId, heartbeatRunId: newerRunId,
|
||||
leasePolicy: "reuse_by_environment", provider: f.lease.provider!, providerLeaseId: f.lease.providerLeaseId,
|
||||
metadata: { ...f.lease.metadata, agentId: f.run.agentId }, replacesReusableLeaseId: f.lease.id,
|
||||
});
|
||||
const expired = await readLease(f.lease.id);
|
||||
expect(expired.status).toBe("expired");
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease);
|
||||
expect(await readLease(f.lease.id)).toEqual(expired);
|
||||
expect(await readLease(newer.id)).toEqual(newer);
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("rolls back the stopped-source hold and action if recording cannot commit", async () => {
|
||||
const f = await seed();
|
||||
const stopped = await stopAndRetain(f);
|
||||
await expect(db.transaction(async tx => {
|
||||
await recordLegacyWorkspaceRestoreFailure(tx as unknown as typeof db, f.run, patch.resultJson, f.lease);
|
||||
throw new Error("recording transaction failed");
|
||||
})).rejects.toThrow("recording transaction failed");
|
||||
expect(await readLease(f.lease.id)).toEqual(stopped);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].resultJson).toBeNull();
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves newer task execution while retaining only the original stopped source", async () => {
|
||||
const f = await seed(), newerRunId = randomUUID();
|
||||
await stopAndRetain(f);
|
||||
await db.insert(heartbeatRuns).values({ id: newerRunId, companyId: f.companyId, agentId: f.run.agentId,
|
||||
status: "running", runtimeMode: "legacy", contextSnapshot: { issueId: f.issueId } });
|
||||
await db.update(issues).set({ executionRunId: newerRunId, checkoutRunId: newerRunId }).where(eq(issues.id, f.issueId));
|
||||
const [newerLease] = await db.insert(environmentLeases).values({ companyId: f.companyId, issueId: f.issueId,
|
||||
heartbeatRunId: newerRunId, environmentId: f.lease.environmentId, provider: "daytona", providerLeaseId: randomUUID(),
|
||||
status: "active", leasePolicy: "ephemeral", metadata: { driver: "sandbox", pluginId: randomUUID() },
|
||||
}).returning();
|
||||
await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease);
|
||||
expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure" });
|
||||
expect(await readLease(newerLease.id)).toEqual(newerLease);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
|
||||
.toMatchObject({ status: "in_progress", executionRunId: newerRunId, checkoutRunId: newerRunId });
|
||||
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
|
||||
expect(action.evidence.automaticRecovery).toMatchObject({ replay: "blocked" });
|
||||
});
|
||||
|
||||
it("leaves local lock retry and successful remote cleanup policy unchanged", async () => {
|
||||
const local = await seed({ local: true });
|
||||
const failure = { ...patch, resultJson: { ...patch.resultJson, workspaceRestoreFailure: "restore_lock_timeout" } };
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { captureEnvironmentAcquisitionDiagnostic } from "../services/environment-acquisition-diagnostics.js";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { buildHeartbeatRunStopMetadata, mergeHeartbeatRunStopMetadata } from "../services/heartbeat-stop-metadata.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { collectRunFailureDiagnostics, sanitizeRunFailureDiagnostics } from "../services/run-failure-diagnostics.js";
|
||||
import type { heartbeatRuns } from "@paperclipai/db";
|
||||
@@ -39,10 +43,90 @@ afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.doUnmock("@sentry/node");
|
||||
vi.doUnmock("../peer-version-check.js");
|
||||
vi.doUnmock("@paperclipai/adapter-utils/execution-target");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK", () => {
|
||||
it("captures an early OpenCode timeout without inventing a deadline or exporting provider output", async () => {
|
||||
const Sentry = sentryPackage!;
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-sentry-"));
|
||||
const restore = vi.fn(async () => {});
|
||||
const stop = vi.fn(async () => {});
|
||||
const processResult = {
|
||||
exitCode: null, signal: null, timedOut: true, pid: null,
|
||||
startedAt: "2020-01-01T00:00:00.000Z", durationMs: 100,
|
||||
stdout: "private-timeout-output-canary",
|
||||
stderr: "<html>504 Gateway Time-out private-timeout-credential-canary</html>",
|
||||
};
|
||||
const runProcess = vi.fn(async () => processResult);
|
||||
vi.stubEnv("XDG_CONFIG_HOME", root);
|
||||
vi.stubEnv("SENTRY_DSN_BACKEND", "https://public@example.invalid/1");
|
||||
vi.doMock("../peer-version-check.js", () => ({ checkExactPeerVersions: () => ({ ok: true }) }));
|
||||
vi.doMock("@sentry/node", () => ({ ...Sentry,
|
||||
init: (options: Record<string, unknown>) => Sentry.init({ ...options,
|
||||
transport: () => ({ send: async (envelope: [unknown, Array<[{ type: string }, Record<string, unknown>]>]) => {
|
||||
for (const [header, payload] of envelope[1]) if (header.type === "event") events.push(payload);
|
||||
return {};
|
||||
}, flush: async () => true }),
|
||||
}),
|
||||
}));
|
||||
// Only external execution/setup seams are replaced. The real adapter owns
|
||||
// the message/policy, and the real finalization/collector/SDK send the event.
|
||||
vi.doMock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("@paperclipai/adapter-utils/execution-target")>(),
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(),
|
||||
ensureAdapterExecutionTargetCommandResolvable: vi.fn(),
|
||||
prepareAdapterExecutionTargetRuntime: vi.fn(async () => ({
|
||||
runtimeRootDir: "/workspace/runtime", workspaceRemoteDir: "/workspace",
|
||||
assetDirs: {}, restoreWorkspace: restore,
|
||||
})),
|
||||
readAdapterExecutionTargetHomeDir: vi.fn(async () => "/home/fixture"),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ env: {}, stop })),
|
||||
runAdapterExecutionTargetProcess: runProcess,
|
||||
}));
|
||||
vi.resetModules();
|
||||
try {
|
||||
const { execute } = await import("@paperclipai/adapter-opencode-local/server");
|
||||
const { sentryReady, captureRunFailure, captureException } = await import("../sentry.js");
|
||||
await sentryReady;
|
||||
const result = await execute({
|
||||
runId: "22222222-2222-4222-8222-222222222222",
|
||||
agent: { id: "agent-fixture", companyId: "company-fixture", name: "Fixture", adapterType: "opencode_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: { command: "opencode", model: "openai/fixture", cwd: root, timeoutSec: 0,
|
||||
env: { OPENCODE_ALLOW_ALL_MODELS: "1" } },
|
||||
context: {}, onLog: async () => {},
|
||||
executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" },
|
||||
});
|
||||
expect(result).toMatchObject({ timedOut: true, exitCode: null, signal: null });
|
||||
expect(runProcess).toHaveBeenCalledTimes(1);
|
||||
expect(restore).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
const resultJson = mergeHeartbeatRunStopMetadata(result.resultJson,
|
||||
buildHeartbeatRunStopMetadata({ adapterType: "opencode_local", adapterConfig: { timeoutSec: 0 }, outcome: "timed_out" }));
|
||||
const diagnostics = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics({
|
||||
errorCode: "timeout", resultJson,
|
||||
startedAt: new Date("2020-01-01T00:00:00.000Z"), finishedAt: new Date("2020-01-01T00:00:00.100Z"),
|
||||
} as typeof heartbeatRuns.$inferSelect, { phase: "execute" }));
|
||||
captureRunFailure({ taskId: "11111111-1111-4111-8111-111111111111", runId: "22222222-2222-4222-8222-222222222222",
|
||||
errorMessage: result.errorMessage!, errorCode: "timeout", agentAdapter: "opencode_local",
|
||||
runStatus: "timed_out", exitCode: result.exitCode, signal: result.signal, diagnostics });
|
||||
captureException(new Error("Unrelated timeout fixture"));
|
||||
await Sentry.flush(2000);
|
||||
expect(events).toHaveLength(2);
|
||||
const event = events.find(entry => (entry.tags as Record<string, unknown>)?.error_code === "timeout");
|
||||
expect(event).toMatchObject({ exception: { values: [{ value: "OpenCode execution timed out" }] },
|
||||
contexts: { run_execution: { durationMs: 100, effectiveTimeoutSec: 14400,
|
||||
timeoutSource: "sandbox_default", timeoutConfigured: false, timeoutFired: true, stopReason: "timeout" } } });
|
||||
expect(JSON.stringify(events)).not.toMatch(/private-timeout-|<html>|504|Timed out after/);
|
||||
expect(events.find(entry => entry !== event)).not.toHaveProperty("contexts.run_execution");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sends only owned acquisition observations and isolates unrelated captures", async () => {
|
||||
const Sentry = sentryPackage!;
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
|
||||
+149
-7
@@ -3,6 +3,11 @@ import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "
|
||||
import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js";
|
||||
import type { IssuePrivacyConstraints } from "@paperclipai/shared";
|
||||
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
|
||||
import {
|
||||
loadCreationSourceAgent,
|
||||
loadRunBoundIssue,
|
||||
resolveIssueCreationSource,
|
||||
} from "../services/issue-creation-source.js";
|
||||
import { activeIssueInteractionCondition, readTaskQuestionContext } from "../services/issue-question-context.js";
|
||||
import { setIssueTitle } from "../services/issue-title.js";
|
||||
import { setIssueTitleSchema } from "@paperclipai/shared";
|
||||
@@ -113,6 +118,7 @@ import {
|
||||
isMarkdownArtifactWorkProduct,
|
||||
isMarkdownAttachmentContent,
|
||||
isUuidLike,
|
||||
type IssueCreationSource,
|
||||
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
||||
type CompactIssue,
|
||||
type CompanySearchExtractQuery,
|
||||
@@ -3756,6 +3762,124 @@ export function issueRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default structural parent for a delegated follow-up: the task the agent's
|
||||
* run is executing. Returns null when the new task should stay standalone:
|
||||
* no run-bound task, a conversation (chat handoffs are top-level by design),
|
||||
* a task the agent may not mutate, a parent that would create a delegation
|
||||
* cycle with the requested assignee, or a parent the agent may not create
|
||||
* children under (for example a protected assignment policy without a
|
||||
* grant). The default must never turn a previously allowed standalone
|
||||
* create into a denial; an explicitly requested parent is still rejected by
|
||||
* the ordinary assignment check. Callers only use this when the request
|
||||
* omitted `parentId`; an explicit `parentId: null` keeps the task standalone.
|
||||
*/
|
||||
async function resolveRunDelegationParentDefault(
|
||||
req: Request,
|
||||
companyId: string,
|
||||
rawCreateBody: Omit<
|
||||
Parameters<typeof resolveCreateAssignmentProjectId>[0],
|
||||
"companyId" | "parentId"
|
||||
> & { assigneeUserId?: string | null },
|
||||
assigneeAgentId: string | null,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
req.actor.type !== "agent" ||
|
||||
!req.actor.agentId ||
|
||||
!req.actor.runId ||
|
||||
!isUuidLike(req.actor.runId)
|
||||
)
|
||||
return null;
|
||||
const run = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, req.actor.runId),
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!run || run.agentId !== req.actor.agentId) return null;
|
||||
const source = await loadRunBoundIssue(db, run);
|
||||
if (!source || isConversation(source)) return null;
|
||||
const decision = await decideIssueAccess(req, source, "issue:mutate");
|
||||
if (!decision.allowed) return null;
|
||||
if (assigneeAgentId && assigneeAgentId !== req.actor.agentId) {
|
||||
const ancestor = await svc.findOpenAncestorCreatedByAgent(
|
||||
source.id,
|
||||
assigneeAgentId,
|
||||
);
|
||||
if (ancestor) return null;
|
||||
}
|
||||
// Mirror the assignment check the create path runs with the defaulted
|
||||
// parent in scope. A protected parent (or project it supplies) denies
|
||||
// child creation without a grant; that must fall back to standalone work
|
||||
// rather than fail a request that succeeded before the default existed.
|
||||
const assignmentScope: TaskAssignmentAuthorizationScope = {
|
||||
projectId: await resolveCreateAssignmentProjectId({
|
||||
...rawCreateBody,
|
||||
companyId,
|
||||
parentId: source.id,
|
||||
}),
|
||||
parentIssueId: source.id,
|
||||
assigneeAgentId,
|
||||
assigneeUserId:
|
||||
typeof rawCreateBody.assigneeUserId === "string"
|
||||
? rawCreateBody.assigneeUserId
|
||||
: null,
|
||||
};
|
||||
const assignment = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "tasks:assign",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId,
|
||||
issueId: null,
|
||||
projectId: assignmentScope.projectId ?? null,
|
||||
parentIssueId: source.id,
|
||||
assigneeAgentId,
|
||||
assigneeUserId: assignmentScope.assigneeUserId ?? null,
|
||||
},
|
||||
scope: assignmentScope,
|
||||
});
|
||||
if (!assignment.allowed) return null;
|
||||
return source.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation provenance the viewer may see: the source task must be readable
|
||||
* and the run must be visible to the actor. Returns null otherwise, so a
|
||||
* private or foreign source is indistinguishable from no provenance.
|
||||
*/
|
||||
async function resolveVisibleIssueCreationSource(
|
||||
req: Request,
|
||||
issue: { id: string; companyId: string; originRunId: string | null },
|
||||
): Promise<IssueCreationSource | null> {
|
||||
const source = await resolveIssueCreationSource(db, issue);
|
||||
if (!source) return null;
|
||||
const { run, sourceIssue } = source;
|
||||
const readable = await decideIssueAccess(req, sourceIssue, "issue:read");
|
||||
if (!readable.allowed) return null;
|
||||
const runVisible = await canActorReadHeartbeatRun(db, access, req.actor, {
|
||||
companyId: run.companyId,
|
||||
scopeKind: run.scopeKind,
|
||||
issueId: run.issueId ?? run.nativeIssueId ?? sourceIssue.id,
|
||||
});
|
||||
if (!runVisible) return null;
|
||||
const agent = await loadCreationSourceAgent(db, issue.companyId, run.agentId);
|
||||
return {
|
||||
issue: {
|
||||
id: sourceIssue.id,
|
||||
identifier: sourceIssue.identifier,
|
||||
title: sourceIssue.title,
|
||||
status: sourceIssue.status,
|
||||
},
|
||||
run: { id: run.id, agentId: run.agentId },
|
||||
agent,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAgentTrustForIssue(
|
||||
input: {
|
||||
agentId: string | null | undefined;
|
||||
@@ -9129,6 +9253,7 @@ export function issueRoutes(
|
||||
externalChannelBinding,
|
||||
currentExecutionWorkspace,
|
||||
workProducts,
|
||||
createdFrom,
|
||||
] = await Promise.all([
|
||||
timing.time("project_goal", () => resolveIssueProjectAndGoal(issue)),
|
||||
timing.time("ancestors", () => svc.getAncestors(issue.id)),
|
||||
@@ -9152,6 +9277,7 @@ export function issueRoutes(
|
||||
? executionWorkspacesSvc.getById(issue.executionWorkspaceId)
|
||||
: Promise.resolve(null)),
|
||||
timing.time("work_products", () => workProductsSvc.listForIssue(issue.id)),
|
||||
timing.time("created_from", () => resolveVisibleIssueCreationSource(req, issue)),
|
||||
]);
|
||||
const [recoveryActionsByRelationIssue, revalidatedActiveRecoveryAction, mentionedProjects] = await Promise.all([
|
||||
timing.time("relation_recovery", () => relationRecoveryActionMap(recoveryActionsSvc, issue.companyId, relations)),
|
||||
@@ -9201,6 +9327,7 @@ export function issueRoutes(
|
||||
workProducts,
|
||||
linkedCases,
|
||||
externalChannelBinding,
|
||||
createdFrom,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12044,9 +12171,27 @@ export function issueRoutes(
|
||||
watchdogDiscovery,
|
||||
);
|
||||
if (watchdogProductBugFollowUp === false) return;
|
||||
const normalizedAssigneeAgentId =
|
||||
await normalizeIssueAssigneeAgentReference(
|
||||
companyId,
|
||||
rawCreateBody.assigneeAgentId as string | null | undefined,
|
||||
{ actorType: req.actor.type },
|
||||
);
|
||||
// A delegated follow-up from an ordinary execution task keeps that task as
|
||||
// its structural parent when the agent omits `parentId`. An explicit
|
||||
// `parentId: null` is an intentional standalone task and is honored.
|
||||
const runDelegationParentId =
|
||||
!watchdogProductBugFollowUp && rawCreateBody.parentId === undefined
|
||||
? await resolveRunDelegationParentDefault(
|
||||
req,
|
||||
companyId,
|
||||
rawCreateBody,
|
||||
normalizedAssigneeAgentId ?? null,
|
||||
)
|
||||
: null;
|
||||
const effectiveParentId = watchdogProductBugFollowUp
|
||||
? null
|
||||
: rawCreateBody.parentId;
|
||||
: (runDelegationParentId ?? rawCreateBody.parentId);
|
||||
let createParent: Awaited<ReturnType<typeof svc.getById>> | null = null;
|
||||
if (req.actor.type === "agent" && effectiveParentId) {
|
||||
createParent = await svc.getById(effectiveParentId);
|
||||
@@ -12070,12 +12215,6 @@ export function issueRoutes(
|
||||
))
|
||||
)
|
||||
return;
|
||||
const normalizedAssigneeAgentId =
|
||||
await normalizeIssueAssigneeAgentReference(
|
||||
companyId,
|
||||
rawCreateBody.assigneeAgentId as string | null | undefined,
|
||||
{ actorType: req.actor.type },
|
||||
);
|
||||
await assertNoAgentDelegationCycle({
|
||||
actorType: req.actor.type,
|
||||
actorAgentId: req.actor.agentId,
|
||||
@@ -12312,6 +12451,9 @@ export function issueRoutes(
|
||||
details: {
|
||||
title: issue.title,
|
||||
identifier: issue.identifier,
|
||||
...(runDelegationParentId && issue.parentId === runDelegationParentId
|
||||
? { parentId: issue.parentId, parentDefaultedFromRunIssue: true }
|
||||
: {}),
|
||||
...(watchdogProductBugFollowUp
|
||||
? {
|
||||
watchdogDiscovery: {
|
||||
|
||||
@@ -4811,6 +4811,9 @@ export function heartbeatService(
|
||||
await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace);
|
||||
const workspaceRealization = realizationResult.workspaceRealization;
|
||||
const executionTarget = realizationResult.executionTarget;
|
||||
// Preserve the host-owned source before adapter context can share lease
|
||||
// metadata. A later copy-back failure must not adopt a rebound source.
|
||||
const workspaceRestoreSource = structuredClone(realizationResult.lease);
|
||||
let instructionCopy: Awaited<ReturnType<typeof instructionCopies.prepare>> = null;
|
||||
let instructionSave: Record<string, unknown> | null = null;
|
||||
const instructionPreparationKey = createHash("sha256").update(JSON.stringify({
|
||||
@@ -7234,7 +7237,7 @@ export function heartbeatService(
|
||||
};
|
||||
// Retention is the fallback even if recording this receipt fails.
|
||||
providerResourceDispositionForRun = "stop_and_retain";
|
||||
await recordLegacyWorkspaceRestoreFailure(db, run, requiredWorkspaceRestoreEvidence);
|
||||
await recordLegacyWorkspaceRestoreFailure(db, run, requiredWorkspaceRestoreEvidence, workspaceRestoreSource);
|
||||
}
|
||||
for (const stream of ["stdout", "stderr"] as const) {
|
||||
const tail = identityRedactor.finish(stream);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { and, asc, eq, inArray, isNotNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { isUuidLike } from "@paperclipai/shared";
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
||||
|
||||
/** The task a run was executing when it created another task. */
|
||||
export interface IssueCreationSourceRecord {
|
||||
run: HeartbeatRunRow;
|
||||
sourceIssue: IssueRow;
|
||||
}
|
||||
|
||||
const CREATION_ACTIVITY_ACTIONS = ["issue.created", "issue.child_created"];
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The issue a heartbeat run is bound to: native run identity first, then the
|
||||
* run's issue scope, then the persisted legacy task context. Returns a UUID or
|
||||
* an issue identifier such as `PAP-168`; the caller resolves identifiers.
|
||||
*/
|
||||
export function runBoundIssueReference(run: Pick<HeartbeatRunRow, "nativeIssueId" | "issueId" | "contextSnapshot">): string | null {
|
||||
const context = run.contextSnapshot && typeof run.contextSnapshot === "object"
|
||||
? (run.contextSnapshot as Record<string, unknown>)
|
||||
: null;
|
||||
const paperclipIssue = context?.paperclipIssue && typeof context.paperclipIssue === "object"
|
||||
? (context.paperclipIssue as Record<string, unknown>)
|
||||
: null;
|
||||
return (
|
||||
run.nativeIssueId ??
|
||||
run.issueId ??
|
||||
readNonEmptyString(context?.issueId) ??
|
||||
readNonEmptyString(context?.taskId) ??
|
||||
readNonEmptyString(paperclipIssue?.id) ??
|
||||
readNonEmptyString(context?.taskKey)
|
||||
);
|
||||
}
|
||||
|
||||
/** Loads the run's bound issue within the run's company, or null. */
|
||||
export async function loadRunBoundIssue(
|
||||
db: Db,
|
||||
run: Pick<HeartbeatRunRow, "companyId" | "nativeIssueId" | "issueId" | "contextSnapshot">,
|
||||
): Promise<IssueRow | null> {
|
||||
const reference = runBoundIssueReference(run);
|
||||
if (!reference) return null;
|
||||
const condition = isUuidLike(reference)
|
||||
? eq(issues.id, reference)
|
||||
: eq(issues.identifier, reference);
|
||||
const [issue] = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(condition, eq(issues.companyId, run.companyId)))
|
||||
.limit(1);
|
||||
return issue ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the task from which an issue was created, using recorded run
|
||||
* provenance: the issue's origin run, or for historical rows without one, the
|
||||
* run on its creation activity. Company-scoped; a run or source task from
|
||||
* another company never resolves. Does not check viewer access.
|
||||
*/
|
||||
export async function resolveIssueCreationSource(
|
||||
db: Db,
|
||||
issue: Pick<IssueRow, "id" | "companyId" | "originRunId">,
|
||||
): Promise<IssueCreationSourceRecord | null> {
|
||||
let runId = issue.originRunId;
|
||||
if (!runId) {
|
||||
const [creation] = await db
|
||||
.select({ runId: activityLog.runId })
|
||||
.from(activityLog)
|
||||
.where(and(
|
||||
eq(activityLog.companyId, issue.companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
eq(activityLog.entityId, issue.id),
|
||||
inArray(activityLog.action, CREATION_ACTIVITY_ACTIONS),
|
||||
isNotNull(activityLog.runId),
|
||||
))
|
||||
.orderBy(asc(activityLog.createdAt))
|
||||
.limit(1);
|
||||
runId = creation?.runId ?? null;
|
||||
}
|
||||
if (!runId || !isUuidLike(runId)) return null;
|
||||
const [run] = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId)))
|
||||
.limit(1);
|
||||
if (!run) return null;
|
||||
const sourceIssue = await loadRunBoundIssue(db, run);
|
||||
if (!sourceIssue || sourceIssue.id === issue.id) return null;
|
||||
return { run, sourceIssue };
|
||||
}
|
||||
|
||||
/** Company-scoped agent summary for a creation source. */
|
||||
export async function loadCreationSourceAgent(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): Promise<{ id: string; name: string } | null> {
|
||||
const [agent] = await db
|
||||
.select({ id: agents.id, name: agents.name })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)))
|
||||
.limit(1);
|
||||
return agent ?? null;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
type IssueReadPhase = "lookup" | "authorization" | "project_goal" | "ancestors"
|
||||
| "mentions" | "documents" | "relations" | "blockers" | "review"
|
||||
| "references" | "handoff" | "retry" | "recovery" | "cases" | "inbox"
|
||||
| "channel" | "workspace" | "work_products" | "execution_blocker"
|
||||
| "channel" | "workspace" | "work_products" | "created_from" | "execution_blocker"
|
||||
| "relation_recovery" | "revalidate_recovery" | "mentioned_projects";
|
||||
|
||||
export function createIssueReadTiming() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { hasRequiredWorkspaceRecovery, preserveWorkspaceRestoreRecoveryMetadata, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js";
|
||||
import { hasUnrestoredRemoteWorkspace, preserveLegacyWorkspaceRestoreSources } from "./legacy-workspace-restore-recovery.js";
|
||||
import { hasUnrestoredRemoteWorkspace, preserveLegacyWorkspaceRestoreSources, type LegacyWorkspaceRestoreSource } from "./legacy-workspace-restore-recovery.js";
|
||||
import { isPreDispatchReviewWaitVerified } from "./pre-dispatch-review-wait.js";
|
||||
import { hasWorkspaceRestoreFailure } from "@paperclipai/shared";
|
||||
import { normalizeMaxTurnStopReason } from "./heartbeat-stop-metadata.js";
|
||||
@@ -74,6 +74,7 @@ export async function terminalizeLegacyExecution(input: {
|
||||
writeConditions?: SQL[];
|
||||
/** Adapter settlement records a copy-back failure before host finalization. */
|
||||
recordRestoreFailureOnly?: boolean;
|
||||
workspaceRestoreSource?: LegacyWorkspaceRestoreSource;
|
||||
}) {
|
||||
const { db, run, status } = input;
|
||||
let patch = input.patch;
|
||||
@@ -122,7 +123,8 @@ export async function terminalizeLegacyExecution(input: {
|
||||
)
|
||||
.returning();
|
||||
if (!updated) return null;
|
||||
const retainedLeaseIds = await preserveLegacyWorkspaceRestoreSources(tx as unknown as Db, input.recordRestoreFailureOnly ? { ...updated, status: "failed" } : updated);
|
||||
const retainedLeaseIds = await preserveLegacyWorkspaceRestoreSources(tx as unknown as Db,
|
||||
input.recordRestoreFailureOnly ? { ...updated, status: "failed" } : updated, input.workspaceRestoreSource);
|
||||
if (retainedLeaseIds.length) {
|
||||
[updated] = await tx.update(heartbeatRuns).set({ resultJson: {
|
||||
...updated.resultJson,
|
||||
@@ -310,9 +312,9 @@ export async function settleInterruptedNativeBootstrap(
|
||||
|
||||
/** Capture the required-file obligation before unrelated host writes. Keeps
|
||||
* execution ownership while the adapter's host finalization is still running. */
|
||||
export async function recordLegacyWorkspaceRestoreFailure(db: Db, run: Run, evidence: Record<string, unknown>) {
|
||||
export async function recordLegacyWorkspaceRestoreFailure(db: Db, run: Run, evidence: Record<string, unknown>, source?: LegacyWorkspaceRestoreSource) {
|
||||
if (run.runtimeMode !== "legacy" || !hasWorkspaceRestoreFailure(evidence)) return;
|
||||
await terminalizeLegacyExecution({ db, run, status: run.status,
|
||||
recordRestoreFailureOnly: true, patch: { resultJson: evidence },
|
||||
recordRestoreFailureOnly: true, patch: { resultJson: evidence }, workspaceRestoreSource: source,
|
||||
});
|
||||
}
|
||||
@@ -1,28 +1,57 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { and, eq, inArray, isNotNull, ne, or, sql } from "drizzle-orm";
|
||||
import { environmentLeases, environments, heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { hasWorkspaceRestoreFailure, WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared";
|
||||
import { prepareSandboxStopAndRetain } from "./sandbox-stop-and-retain.js";
|
||||
import { hasConfirmedSandboxStopAndRetain, prepareSandboxStopAndRetain } from "./sandbox-stop-and-retain.js";
|
||||
|
||||
import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js";
|
||||
|
||||
type Run = typeof heartbeatRuns.$inferSelect;
|
||||
/** The host's realized lease snapshot from before adapter execution. Never
|
||||
* reconstruct this identity from a terminal status or a search of old leases. */
|
||||
export type LegacyWorkspaceRestoreSource = Pick<typeof environmentLeases.$inferSelect,
|
||||
"id" | "companyId" | "heartbeatRunId" | "environmentId" | "issueId" | "executionWorkspaceId"
|
||||
| "provider" | "providerLeaseId" | "acquiredAt" | "leasePolicy" | "metadata">;
|
||||
|
||||
function needsRemoteRestore(run: Run): boolean {
|
||||
return run.runtimeMode === "legacy" && ["failed", "timed_out", "interrupted", "cancelled"].includes(run.status)
|
||||
&& hasWorkspaceRestoreFailure(run.resultJson);
|
||||
}
|
||||
|
||||
function leaseScope(run: Run) {
|
||||
function leaseScope(run: Run, source?: LegacyWorkspaceRestoreSource) {
|
||||
return and(eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
||||
or(eq(environmentLeases.status, "active"), and(
|
||||
sql`${environmentLeases.metadata}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}`,
|
||||
sql`${environmentLeases.metadata}->'workspaceRestoreRecovery'->>'runId' = ${run.id}`)),
|
||||
sql`${environmentLeases.metadata}->'workspaceRestoreRecovery'->>'runId' = ${run.id}`),
|
||||
source ? and(eq(environmentLeases.id, source.id), eq(environmentLeases.status, "released")) : undefined),
|
||||
isNotNull(environmentLeases.providerLeaseId),
|
||||
isNotNull(environmentLeases.provider), ne(environmentLeases.provider, "local"),
|
||||
or(eq(environments.driver, "sandbox"), sql`${environmentLeases.metadata}->>'driver' = 'sandbox'`,
|
||||
sql`${environmentLeases.metadata}->>'sandboxProviderPlugin' = 'true'`));
|
||||
}
|
||||
|
||||
function matchesOriginalSource(lease: typeof environmentLeases.$inferSelect, run: Run, source: LegacyWorkspaceRestoreSource) {
|
||||
const issueId = run.nativeIssueId ?? run.issueId ?? run.contextSnapshot?.issueId ?? null;
|
||||
const workspaceId = run.contextSnapshot?.executionWorkspaceId;
|
||||
const remoteCwd = source.metadata?.remoteCwd;
|
||||
const realization = source.metadata?.workspaceRealization;
|
||||
if (typeof remoteCwd !== "string" || !remoteCwd.trim()
|
||||
|| (typeof workspaceId === "string" && source.executionWorkspaceId !== workspaceId)) return false;
|
||||
if (realization !== undefined && realization !== null) {
|
||||
if (typeof realization !== "object" || Array.isArray(realization)) return false;
|
||||
const root = (realization as Record<string, unknown>).authoritativeRoot;
|
||||
if (root !== undefined && (typeof root !== "string" || !root.trim())) return false;
|
||||
}
|
||||
return source.leasePolicy === "ephemeral"
|
||||
&& lease.leasePolicy === source.leasePolicy
|
||||
&& source.companyId === run.companyId && source.heartbeatRunId === run.id && source.issueId === issueId
|
||||
&& (["id", "companyId", "heartbeatRunId", "environmentId", "issueId", "executionWorkspaceId", "provider", "providerLeaseId"] as const)
|
||||
.every(key => lease[key] === source[key])
|
||||
&& lease.acquiredAt.getTime() === source.acquiredAt.getTime()
|
||||
&& (["pluginId", "sandboxProviderPlugin", "remoteCwd", "workspaceRealization"] as const)
|
||||
.every(key => isDeepStrictEqual(lease.metadata?.[key], source.metadata?.[key]));
|
||||
}
|
||||
|
||||
/** A local lock timeout has no remote source to retain and keeps its old policy. */
|
||||
export async function hasUnrestoredRemoteWorkspace(db: Db, run: Run): Promise<boolean> {
|
||||
if (!needsRemoteRestore(run)) return false;
|
||||
@@ -35,14 +64,40 @@ export async function hasUnrestoredRemoteWorkspace(db: Db, run: Run): Promise<bo
|
||||
/** Call in the terminal-status transaction. No cleanup worker may observe the
|
||||
* failed run without its exact stop-only intent. The original allocation must
|
||||
* not enter warm reuse; it belongs to the board's workspace repair action. */
|
||||
export async function preserveLegacyWorkspaceRestoreSources(db: Db, run: Run): Promise<string[]> {
|
||||
export async function preserveLegacyWorkspaceRestoreSources(db: Db, run: Run, source?: LegacyWorkspaceRestoreSource): Promise<string[]> {
|
||||
if (!needsRemoteRestore(run)) return [];
|
||||
const rows = await db.select({ lease: environmentLeases }).from(environmentLeases)
|
||||
.leftJoin(environments, eq(environments.id, environmentLeases.environmentId))
|
||||
.where(leaseScope(run)).for("update", { of: environmentLeases });
|
||||
.where(leaseScope(run, source)).for("update", { of: environmentLeases });
|
||||
const retained: string[] = [];
|
||||
for (const { lease } of rows) {
|
||||
if (lease.status !== "active") { retained.push(lease.id); continue; }
|
||||
if (lease.status !== "active") {
|
||||
const recovery = lease.metadata?.workspaceRestoreRecovery as Record<string, unknown> | undefined;
|
||||
if (recovery?.schema === LEGACY_WORKSPACE_RECOVERY_SCHEMA && recovery.runId === run.id) {
|
||||
retained.push(lease.id); continue;
|
||||
}
|
||||
// Cancellation can finish the dedicated stop before copy-back reports its
|
||||
// failure. Only the host's original source and two matching receipts can
|
||||
// turn that released row into a file-repair hold.
|
||||
if (!source || !matchesOriginalSource(lease, run, source) || !hasConfirmedSandboxStopAndRetain(lease)) continue;
|
||||
const [other] = await db.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
|
||||
ne(environmentLeases.id, lease.id), eq(environmentLeases.provider, lease.provider!),
|
||||
eq(environmentLeases.providerLeaseId, lease.providerLeaseId!),
|
||||
)).limit(1);
|
||||
// An ephemeral source has no legitimate reuse history. Any other physical
|
||||
// owner makes retention ambiguous, including an older owner that later
|
||||
// destroyed the allocation. Do not expose cross-company owner identity.
|
||||
if (other) continue;
|
||||
await db.update(environmentLeases).set({ leasePolicy: "retain_on_failure",
|
||||
metadata: sql`${environmentLeases.metadata} || ${JSON.stringify({ workspaceRestoreRecovery: {
|
||||
schema: LEGACY_WORKSPACE_RECOVERY_SCHEMA, runId: run.id,
|
||||
} })}::jsonb`,
|
||||
}).where(eq(environmentLeases.id, lease.id));
|
||||
// Reusable sources are excluded: provider resume precedes the database
|
||||
// handoff, so a row lock cannot fence an in-flight resume. Keep the release
|
||||
// status and receipts; this repair hold grants no execute right.
|
||||
retained.push(lease.id); continue;
|
||||
}
|
||||
// The stop-only worker rechecks exact allocation ownership, including
|
||||
// competing leases, before dispatch. Persist the intent even when cleanup
|
||||
// must wait, so no terminal-run sweep can fall back to destruction.
|
||||
|
||||
@@ -2,13 +2,46 @@ import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql, type SQL } from "drizzle-orm";
|
||||
import { environmentLeases, type Db } from "@paperclipai/db";
|
||||
import { isBuiltinSandboxProvider } from "./sandbox-provider-runtime.js";
|
||||
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
import { hasRemoteTerminationReceipt, remoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
import { hasNativeWorkspaceExportResume, readNativeWorkspaceExportResume, settleNativeWorkspaceExportResume } from "./native-runtime/native-workspace-export-resume.js";
|
||||
|
||||
type Lease = Pick<typeof environmentLeases.$inferSelect,
|
||||
"id" | "companyId" | "heartbeatRunId" | "provider" | "providerLeaseId" | "metadata">;
|
||||
export const SANDBOX_STOP_AND_RETAIN_KEY = "sandboxStopAndRetain";
|
||||
|
||||
/** A successful stop is not by itself proof that the allocation was retained.
|
||||
* Require the dedicated stop-only dispatch receipt and its independent stop
|
||||
* confirmation, both bound to the original lease. No provider work is allowed. */
|
||||
export function hasConfirmedSandboxStopAndRetain(lease: typeof environmentLeases.$inferSelect): boolean {
|
||||
const acquiredAt = lease.acquiredAt.getTime();
|
||||
const releasedAt = lease.releasedAt?.getTime();
|
||||
const expiresAt = lease.expiresAt?.getTime();
|
||||
if (!Number.isFinite(acquiredAt) || releasedAt === undefined || !Number.isFinite(releasedAt)
|
||||
|| (expiresAt !== undefined && (!Number.isFinite(expiresAt) || expiresAt <= Date.now()))) return false;
|
||||
const value = lease.metadata?.sandboxStopAndRetainReceipt;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const receipt = value as Record<string, unknown>;
|
||||
const termination = lease.metadata?.remoteExecutionTermination as Record<string, unknown> | undefined;
|
||||
if (lease.status !== "released" || !hasRemoteTerminationReceipt(lease)
|
||||
|| termination?.state !== "stopped" || hasStopOnlyCleanup(lease)
|
||||
|| lease.metadata?.pendingCleanupInFlight === true
|
||||
|| receipt.schema !== "paperclip.sandbox-stop-and-retain-receipt.v1"
|
||||
|| receipt.companyId !== lease.companyId || receipt.runId !== lease.heartbeatRunId
|
||||
|| receipt.leaseId !== lease.id || receipt.provider !== lease.provider
|
||||
|| receipt.providerLeaseId !== lease.providerLeaseId
|
||||
|| typeof receipt.requestId !== "string" || !receipt.requestId.trim()
|
||||
|| typeof receipt.confirmedAt !== "string" || receipt.confirmedAt !== termination.confirmedAt) return false;
|
||||
const confirmedAt = Date.parse(receipt.confirmedAt);
|
||||
if (!Number.isFinite(confirmedAt) || confirmedAt < acquiredAt || confirmedAt > releasedAt) return false;
|
||||
if (receipt.builtinProvider !== undefined) {
|
||||
return receipt.builtinProvider === lease.provider && isBuiltinSandboxProvider(lease.provider!)
|
||||
&& receipt.method === "builtin.stopLease" && receipt.pluginId === undefined
|
||||
&& lease.metadata?.pluginId == null && !lease.metadata?.sandboxProviderPlugin;
|
||||
}
|
||||
return receipt.method === "environmentStopLease" && typeof receipt.pluginId === "string"
|
||||
&& !!receipt.pluginId.trim() && receipt.pluginId === lease.metadata?.pluginId;
|
||||
}
|
||||
|
||||
export function hasStopOnlyCleanup(lease: Pick<Lease, "metadata">): boolean {
|
||||
return hasNativeWorkspaceExportResume(lease)
|
||||
|| Object.prototype.hasOwnProperty.call(lease.metadata ?? {}, SANDBOX_STOP_AND_RETAIN_KEY);
|
||||
|
||||
@@ -219,7 +219,7 @@ Before ending any heartbeat, apply this final-disposition checklist:
|
||||
- `done`: the requested work is complete, verification is recorded, and no follow-up remains on this issue.
|
||||
- `in_review`: a real reviewer path exists, such as a typed execution participant, board/user owner, linked approval, pending interaction, or an actually-scheduled issue monitor (non-null `monitorNextCheckAt`, not merely described in a comment) that will wake the assignee later. Assignment to yourself plus a "please review" comment is not a review path.
|
||||
- `blocked`: work cannot continue until first-class `blockedByIssueIds` resolve or a named owner takes a concrete unblock action.
|
||||
- Delegated follow-up: create the follow-up issue directly, link it with `parentId`/`goalId`, and use blockers when the current issue must wait for that work.
|
||||
- Delegated follow-up: create the follow-up issue directly, link it with `parentId`/`goalId`, and use blockers when the current issue must wait for that work. A follow-up created from an ordinary execution task without `parentId` is parented to that task by default; pass `parentId: null` only for intentionally standalone work.
|
||||
- Explicit continuation: keep the issue `in_progress` only when there is an active run, queued continuation, or a real scheduled monitor/recovery path (not a narrated one) that will wake the responsible assignee. Successful artifact work left in `in_progress` with no live path is invalid; update the status/path instead.
|
||||
|
||||
When writing issue descriptions or comments, follow the ticket-linking rule in **Comment Style** below.
|
||||
@@ -264,7 +264,7 @@ Because of that, follow these rules:
|
||||
- **Never imply a live watcher on a task you are marking `done`.** `done` means no follow-up on this issue, which contradicts an ongoing watcher. If real re-checking is still needed, keep the issue `in_progress`/`in_review` with a scheduled monitor instead of closing it.
|
||||
- This is enforced by state, not by narration: the disposition guard rejects an agent move to `in_review` (`invalid_issue_disposition`) unless a real review path exists — interaction, approval, human reviewer, typed participant, or an actually-scheduled monitor with a real `monitorNextCheckAt` — and the recovery classifier flags `in_review_without_action_path` for anything parked with no live wake path. Keep your comments consistent with that real state.
|
||||
|
||||
**Step 9 — Delegate if needed.** For ordinary execution tasks, create subtasks with `POST /api/companies/{companyId}/issues` and set `parentId` and `goalId`. For conversation tasks, use the project handoff above instead. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work.
|
||||
**Step 9 — Delegate if needed.** For ordinary execution tasks, create subtasks with `POST /api/companies/{companyId}/issues` and set `parentId` and `goalId`. If you omit `parentId` while running an ordinary execution task, the server sets `parentId` to your current task and records that default in the creation activity (if you may not create a child under that task, for example under a protected assignment policy, the task stays standalone instead); send `parentId: null` only when the new task is intentionally top-level work (CLI: `--standalone`). Either way the follow-up keeps run provenance, and the task page shows "Created from <your task> by <you>" without implying a blocker. For conversation tasks, use the project handoff above instead. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work.
|
||||
|
||||
### Delegating review tasks
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ describe("IssueFiltersPopover", () => {
|
||||
agents={[{ id: "agent-1", name: "Agent One" }]}
|
||||
projects={[{ id: "project-1", name: "Project One" }]}
|
||||
labels={[{ id: "label-1", name: "Bug", color: "#ff0000" }]}
|
||||
workspaces={[{ id: "workspace-1", name: "Workspace One" }]}
|
||||
enableRoutineVisibilityFilter
|
||||
/>,
|
||||
);
|
||||
@@ -73,6 +72,8 @@ describe("IssueFiltersPopover", () => {
|
||||
|
||||
const popoverContent = container.querySelector("[data-testid='popover-content']");
|
||||
expect(popoverContent).not.toBeNull();
|
||||
expect(popoverContent?.querySelector('[data-filter-options="workspaces"]')).toBeNull();
|
||||
expect(popoverContent?.textContent).not.toContain("Workspace");
|
||||
expect(popoverContent?.className).toContain("overflow-y-auto");
|
||||
expect(popoverContent?.className).toContain("max-h-(--sz-calc-9)");
|
||||
expect(popoverContent?.querySelectorAll(".overflow-y-auto").length).toBe(0);
|
||||
@@ -97,7 +98,6 @@ describe("IssueFiltersPopover", () => {
|
||||
agents={[{ id: "agent-1", name: "Agent One" }]}
|
||||
projects={[{ id: "project-1", name: "Project One" }]}
|
||||
labels={[{ id: "label-1", name: "Bug", color: "#ff0000" }]}
|
||||
workspaces={[{ id: "workspace-1", name: "Workspace One" }]}
|
||||
enableRoutineVisibilityFilter
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Bot, Check, Filter, HardDrive, Search, User, X } from "lucide-react";
|
||||
import { Bot, Check, Filter, Search, User, X } from "lucide-react";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
@@ -43,11 +43,6 @@ type LabelOption = {
|
||||
color: string;
|
||||
};
|
||||
|
||||
type WorkspaceOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type CreatorOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -116,7 +111,6 @@ export function IssueFiltersPopover({
|
||||
enableRoutineVisibilityFilter = false,
|
||||
buttonVariant = "ghost",
|
||||
iconOnly = false,
|
||||
workspaces,
|
||||
creators,
|
||||
presentation = "legacy",
|
||||
inboxScopeFilters,
|
||||
@@ -132,7 +126,6 @@ export function IssueFiltersPopover({
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
buttonVariant?: "ghost" | "outline";
|
||||
iconOnly?: boolean;
|
||||
workspaces?: WorkspaceOption[];
|
||||
creators?: CreatorOption[];
|
||||
presentation?: "legacy" | "streamlined";
|
||||
inboxScopeFilters?: InboxScopeFilters;
|
||||
@@ -142,7 +135,6 @@ export function IssueFiltersPopover({
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
const [projectSearch, setProjectSearch] = useState("");
|
||||
const [labelSearch, setLabelSearch] = useState("");
|
||||
const [workspaceSearch, setWorkspaceSearch] = useState("");
|
||||
const visibleAgents = useMemo(
|
||||
() => searchIssueFilterOptions(agents, assigneeSearch, (option) => option.name),
|
||||
[agents, assigneeSearch],
|
||||
@@ -155,10 +147,6 @@ export function IssueFiltersPopover({
|
||||
() => searchIssueFilterOptions(labels, labelSearch, (option) => option.name),
|
||||
[labels, labelSearch],
|
||||
);
|
||||
const visibleWorkspaces = useMemo(
|
||||
() => searchIssueFilterOptions(workspaces, workspaceSearch, (option) => option.name),
|
||||
[workspaces, workspaceSearch],
|
||||
);
|
||||
const creatorOptions = creators ?? [];
|
||||
const creatorOptionById = useMemo(
|
||||
() => new Map(creatorOptions.map((option) => [option.id, option])),
|
||||
@@ -490,27 +478,6 @@ export function IssueFiltersPopover({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{workspaces && workspaces.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Workspace</span>
|
||||
{streamlined && workspaces.length > SEARCHABLE_FILTER_THRESHOLD ? (
|
||||
<FilterOptionSearch value={workspaceSearch} onChange={setWorkspaceSearch} label="Workspaces" />
|
||||
) : null}
|
||||
<div data-filter-options="workspaces" className={streamlined ? "space-y-0.5" : "max-h-32 space-y-0.5 overflow-y-auto"}>
|
||||
{(streamlined ? visibleWorkspaces : workspaces).map((workspace) => (
|
||||
<label key={workspace.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.workspaces.includes(workspace.id)}
|
||||
onCheckedChange={() => onChange({ workspaces: toggleIssueFilterValue(state.workspaces, workspace.id) })}
|
||||
/>
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm">{workspace.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{enableExternalObjectFilters ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">External object status</span>
|
||||
|
||||
@@ -1928,6 +1928,56 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a saved workspace filter when the page address has no workspace query", async () => {
|
||||
localStorage.setItem(
|
||||
taskCollectionPreferencesStorageKey({
|
||||
companyId: "company-1",
|
||||
collectionKey: "paperclip:test-issues",
|
||||
}),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
companyId: "company-1",
|
||||
collectionKey: "paperclip:test-issues",
|
||||
viewState: {
|
||||
workspaces: ["workspace-alpha"],
|
||||
},
|
||||
columns: ["id"],
|
||||
}),
|
||||
);
|
||||
const alphaIssue = createIssue({
|
||||
id: "issue-alpha",
|
||||
identifier: "PAP-40",
|
||||
title: "Alpha issue",
|
||||
executionWorkspaceId: "workspace-alpha",
|
||||
});
|
||||
const betaIssue = createIssue({
|
||||
id: "issue-beta",
|
||||
identifier: "PAP-41",
|
||||
title: "Beta issue",
|
||||
executionWorkspaceId: "workspace-beta",
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[alphaIssue, betaIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Alpha issue");
|
||||
expect(container.textContent).toContain("Beta issue");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("applies an initial workspace filter from the issues URL state", async () => {
|
||||
const alphaIssue = createIssue({
|
||||
id: "issue-alpha",
|
||||
|
||||
@@ -207,6 +207,9 @@ function normalizeIssueViewState(value: unknown): IssueViewState {
|
||||
...defaultViewState,
|
||||
...parsed,
|
||||
...normalizeIssueFilterState(parsed),
|
||||
// A saved workspace selection has no filter control. Drop it on load.
|
||||
// `initialWorkspaces` from the page address is applied after this.
|
||||
workspaces: [],
|
||||
sortField: ["status", "priority", "title", "created", "updated", "workflow"].includes(parsed.sortField ?? "")
|
||||
? parsed.sortField as IssueSortField
|
||||
: defaultViewState.sortField,
|
||||
@@ -1043,16 +1046,6 @@ function StreamlinedIssuesList({
|
||||
return map;
|
||||
}, [defaultProjectWorkspaceIds, executionWorkspaceById, projectWorkspaceById]);
|
||||
|
||||
const workspaceOptions = useMemo(() => {
|
||||
const options = new Map<string, string>();
|
||||
for (const [workspaceId, workspaceName] of workspaceNameMap) {
|
||||
options.set(workspaceId, workspaceName);
|
||||
}
|
||||
return [...options.entries()]
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([id, name]) => ({ id, name }));
|
||||
}, [workspaceNameMap]);
|
||||
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const options = new Map<string, CreatorOption>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
@@ -1920,7 +1913,6 @@ function StreamlinedIssuesList({
|
||||
enableExternalObjectFilters={externalObjectsEnabled}
|
||||
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined}
|
||||
presentation={rowPresentation === "task" ? "streamlined" : "legacy"}
|
||||
/>
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ function getViewState(key: string): IssueViewState {
|
||||
...defaultViewState,
|
||||
...parsed,
|
||||
...normalizeIssueFilterState(parsed),
|
||||
workspaces: [],
|
||||
boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity),
|
||||
boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode),
|
||||
boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize),
|
||||
@@ -965,16 +966,6 @@ export function IssuesList({
|
||||
return map;
|
||||
}, [defaultProjectWorkspaceIds, executionWorkspaceById, projectWorkspaceById]);
|
||||
|
||||
const workspaceOptions = useMemo(() => {
|
||||
const options = new Map<string, string>();
|
||||
for (const [workspaceId, workspaceName] of workspaceNameMap) {
|
||||
options.set(workspaceId, workspaceName);
|
||||
}
|
||||
return [...options.entries()]
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([id, name]) => ({ id, name }));
|
||||
}, [workspaceNameMap]);
|
||||
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const options = new Map<string, CreatorOption>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
@@ -1814,7 +1805,6 @@ export function IssuesList({
|
||||
enableExternalObjectFilters={externalObjectsEnabled}
|
||||
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined}
|
||||
/>
|
||||
|
||||
{/* Sort (list view only) */}
|
||||
|
||||
@@ -931,6 +931,17 @@ describe("MarkdownEditor", () => {
|
||||
expect(mdxEditorMockState.insertedMarkdownValues).toEqual([pasted]);
|
||||
expect(innerPaste).not.toHaveBeenCalled();
|
||||
|
||||
const dashed = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(dashed, "clipboardData", {
|
||||
value: { types: ["text/plain"], getData: () => "- a single dashed line" },
|
||||
});
|
||||
await act(async () => { editable.dispatchEvent(dashed); });
|
||||
expect(dashed.defaultPrevented).toBe(true);
|
||||
expect(mdxEditorMockState.insertedMarkdownValues).toEqual([
|
||||
pasted,
|
||||
"- a single dashed line",
|
||||
]);
|
||||
|
||||
const plain = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(plain, "clipboardData", {
|
||||
value: { types: ["text/plain"], getData: () => "ordinary text" },
|
||||
@@ -941,6 +952,54 @@ describe("MarkdownEditor", () => {
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
|
||||
it("leaves a markdown paste inside a nested code block", async () => {
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MarkdownEditor value="```txt\n\n```" onChange={() => {}} />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const editable = container.querySelector('[data-testid="mdx-editor"]') as HTMLDivElement;
|
||||
const code = document.createElement("div");
|
||||
code.className = "cm-content";
|
||||
code.setAttribute("contenteditable", "true");
|
||||
const text = document.createTextNode("");
|
||||
code.appendChild(text);
|
||||
editable.appendChild(code);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
code.focus();
|
||||
|
||||
const pasted = "- keep this line in the code block\n- and this one";
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
configurable: true,
|
||||
value: {
|
||||
types: ["text/plain"],
|
||||
getData: (type: string) => (type === "text/plain" ? pasted : ""),
|
||||
},
|
||||
});
|
||||
const innerPaste = vi.fn();
|
||||
code.addEventListener("paste", innerPaste);
|
||||
|
||||
await act(async () => {
|
||||
code.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(mdxEditorMockState.insertedMarkdownValues).toEqual([]);
|
||||
expect(innerPaste).toHaveBeenCalledOnce();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes angle brackets in pasted markdown", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
||||
@@ -510,12 +510,22 @@ function getMentionMenuSize(optionCount: number): MentionMenuSize {
|
||||
};
|
||||
}
|
||||
|
||||
// CodeMirror's editable surface is `.cm-content`, not a `pre` or `code` node.
|
||||
// MDXEditor wraps that surface in a hashed `codeMirrorWrapper` class. A paste
|
||||
// that starts inside either of those belongs to the nested editor.
|
||||
const CODE_LIKE_SELECTOR = [
|
||||
"pre",
|
||||
"code",
|
||||
".cm-editor",
|
||||
".cm-content",
|
||||
"[class*='codeMirrorWrapper']",
|
||||
"[class*='codeBlockEditor']",
|
||||
].join(",");
|
||||
|
||||
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
||||
if (!node || !container.contains(node)) return false;
|
||||
const el = node.nodeType === Node.ELEMENT_NODE
|
||||
? (node as HTMLElement)
|
||||
: node.parentElement;
|
||||
return Boolean(el?.closest("pre, code"));
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
return Boolean(el?.closest(CODE_LIKE_SELECTOR));
|
||||
}
|
||||
|
||||
function isSelectionInsideCodeLikeElement(container: HTMLElement | null) {
|
||||
@@ -528,6 +538,17 @@ function isSelectionInsideCodeLikeElement(container: HTMLElement | null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPasteInsideCodeLikeElement(
|
||||
container: HTMLElement | null,
|
||||
event: { target: EventTarget | null },
|
||||
): boolean {
|
||||
if (!container) return false;
|
||||
if (event.target instanceof Node && nodeInsideCodeLike(container, event.target)) return true;
|
||||
const active = typeof document !== "undefined" ? document.activeElement : null;
|
||||
if (active instanceof Node && nodeInsideCodeLike(container, active)) return true;
|
||||
return isSelectionInsideCodeLikeElement(container);
|
||||
}
|
||||
|
||||
/** The human title of an issue mention — `name` minus its leading identifier. */
|
||||
export function issueMentionTitle(option: MentionOption): string {
|
||||
const name = option.name.trim();
|
||||
@@ -1280,7 +1301,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
if (!clipboard || !ref.current) return;
|
||||
const types = new Set(Array.from(clipboard.types));
|
||||
if (types.has("Files") || types.has("text/html")) return;
|
||||
if (isSelectionInsideCodeLikeElement(containerRef.current)) return;
|
||||
if (isPasteInsideCodeLikeElement(containerRef.current, event)) return;
|
||||
|
||||
const rawText = clipboard.getData("text/plain");
|
||||
if (!looksLikeMarkdownPaste(rawText)) return;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RoutineSectionHeading } from "./RoutineSectionHeading";
|
||||
|
||||
const copyTextToClipboard = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("@/lib/clipboard", () => ({
|
||||
copyTextToClipboard,
|
||||
}));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
describe("RoutineSectionHeading", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
afterEach(() => {
|
||||
container?.remove();
|
||||
copyTextToClipboard.mockClear();
|
||||
});
|
||||
|
||||
it("copies the description markdown from the button next to the heading", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const description = "```text\n- keep this line\n```";
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RoutineSectionHeading title="Overview" copyText={description} />);
|
||||
});
|
||||
|
||||
const button = container.querySelector('button[aria-label="Copy description"]');
|
||||
expect(button).not.toBeNull();
|
||||
expect(container.querySelector("#routine-section-title")?.textContent).toBe("Overview");
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(copyTextToClipboard).toHaveBeenCalledWith(description);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the copy button on sections that are not the overview", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RoutineSectionHeading title="Triggers" />);
|
||||
});
|
||||
|
||||
expect(container.querySelector('button[aria-label="Copy description"]')).toBeNull();
|
||||
expect(container.querySelector("#routine-section-title")?.textContent).toBe("Triggers");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Copy } from "lucide-react";
|
||||
import { CopyText } from "./CopyText";
|
||||
|
||||
export function RoutineSectionHeading({
|
||||
title,
|
||||
copyText = null,
|
||||
}: {
|
||||
title: string;
|
||||
/** Description markdown to copy. Omit the button when this is null. */
|
||||
copyText?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-1">
|
||||
<h2 id="routine-section-title" className="text-lg font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
{copyText != null ? (
|
||||
<CopyText
|
||||
text={copyText}
|
||||
ariaLabel="Copy description"
|
||||
title="Copy description"
|
||||
copiedLabel="Description copied"
|
||||
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Copy className="size-3.5" aria-hidden="true" />
|
||||
</CopyText>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { IssueCreationSource } from "@paperclipai/shared";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import {
|
||||
createIssueDetailPath,
|
||||
rememberIssueDetailLocationState,
|
||||
} from "@/lib/issueDetailBreadcrumb";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface IssueCreatedFromNoteProps {
|
||||
/** Access-checked creation provenance from the issue detail response. */
|
||||
createdFrom: IssueCreationSource | null | undefined;
|
||||
issueLinkState?: unknown;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Created from PAP-168 by Paperclip QA": the task a run was executing when it
|
||||
* created this one. Provenance only; it is not the structural parent and it is
|
||||
* not a blocker. Renders nothing when the viewer may not see the source.
|
||||
*/
|
||||
export function IssueCreatedFromNote({ createdFrom, issueLinkState, className }: IssueCreatedFromNoteProps) {
|
||||
if (!createdFrom) return null;
|
||||
const { issue, agent } = createdFrom;
|
||||
const ref = issue.identifier ?? issue.id;
|
||||
return (
|
||||
<p
|
||||
data-testid="issue-created-from"
|
||||
className={cn("flex min-w-0 items-center gap-1 text-xs text-muted-foreground", className)}
|
||||
>
|
||||
<GitBranch aria-hidden className="h-3 w-3 shrink-0" />
|
||||
<span className="shrink-0">Created from</span>
|
||||
<Link
|
||||
to={createIssueDetailPath(ref)}
|
||||
state={issueLinkState}
|
||||
onClickCapture={() => rememberIssueDetailLocationState(ref, issueLinkState)}
|
||||
className="min-w-0 truncate hover:text-foreground transition-colors"
|
||||
title={issue.title}
|
||||
>
|
||||
{issue.identifier ? `${issue.identifier} ${issue.title}` : issue.title}
|
||||
</Link>
|
||||
{agent && <span className="shrink-0">by {agent.name}</span>}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -98,4 +98,30 @@ describe("TaskDetailTasksPanel", () => {
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
expect(container.querySelector('[data-task-id="child"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows the access-checked creation source even when the task has no parent", () => {
|
||||
render({
|
||||
createdFrom: {
|
||||
issue: { id: "source", identifier: "PAP-168", title: "Verify Tailscale fix", status: "in_progress" },
|
||||
run: { id: "run-1", agentId: "qa" },
|
||||
agent: { id: "qa", name: "Paperclip QA" },
|
||||
},
|
||||
subtasks: [], createdTasks: [], projects: [],
|
||||
});
|
||||
const group = container.querySelector('section[aria-label="Created from"]')!;
|
||||
expect(group).not.toBeNull();
|
||||
const note = group.querySelector('[data-testid="issue-created-from"]')!;
|
||||
expect(note.textContent).toContain("Created from");
|
||||
expect(note.textContent).toContain("PAP-168 Verify Tailscale fix");
|
||||
expect(note.textContent).toContain("by Paperclip QA");
|
||||
expect(note.querySelector('a')?.getAttribute("href")).toBe("/issues/PAP-168");
|
||||
expect(container.querySelector('section[aria-label="Ancestors"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("No tasks yet.");
|
||||
});
|
||||
|
||||
it("omits the creation source when the viewer may not see it", () => {
|
||||
render({ createdFrom: null, subtasks: [], createdTasks: [], projects: [] });
|
||||
expect(container.querySelector('section[aria-label="Created from"]')).toBeNull();
|
||||
expect(container.textContent).toContain("No tasks yet.");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { issueStatusOrder } from "@/lib/issue-filters";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { RelationNavigationList, TaskDetailSubtasksPanel, TaskDetailTaskList } from "./TaskDetailRelationsPanel";
|
||||
import { IssueCreatedFromNote } from "./IssueCreatedFromNote";
|
||||
|
||||
function TaskGroup({ name, projectPath, children }: { name: string; projectPath?: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -36,6 +37,8 @@ function TaskGroup({ name, projectPath, children }: { name: string; projectPath?
|
||||
export interface TaskDetailTasksPanelProps {
|
||||
/** API order: immediate parent first. Displayed root first. */
|
||||
ancestors?: NonNullable<Issue["ancestors"]>;
|
||||
/** Access-checked creation provenance; shown even when the source is not the parent. */
|
||||
createdFrom?: Issue["createdFrom"];
|
||||
subtasks: Issue[];
|
||||
createdTasks: Issue[];
|
||||
projects: Project[];
|
||||
@@ -45,7 +48,7 @@ export interface TaskDetailTasksPanelProps {
|
||||
issueLinkState?: unknown;
|
||||
}
|
||||
|
||||
export function TaskDetailTasksPanel({ ancestors = [], subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) {
|
||||
export function TaskDetailTasksPanel({ ancestors = [], createdFrom = null, subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) {
|
||||
const sortedSubtasks = sortTasks(subtasks);
|
||||
const groups = new Map<string, { name: string; path?: string; tasks: Issue[] }>();
|
||||
for (const item of sortTasks(createdTasks)) {
|
||||
@@ -63,6 +66,11 @@ export function TaskDetailTasksPanel({ ancestors = [], subtasks, createdTasks, p
|
||||
}
|
||||
return (
|
||||
<section className="flex flex-col gap-6" aria-label="Related tasks">
|
||||
{createdFrom && (
|
||||
<TaskGroup name="Created from">
|
||||
<IssueCreatedFromNote createdFrom={createdFrom} issueLinkState={issueLinkState} className="px-2 text-sm" />
|
||||
</TaskGroup>
|
||||
)}
|
||||
{ancestors.length > 0 && (
|
||||
<TaskGroup name="Ancestors">
|
||||
<RelationNavigationList
|
||||
@@ -90,7 +98,7 @@ export function TaskDetailTasksPanel({ ancestors = [], subtasks, createdTasks, p
|
||||
{onRetry && <Button variant="ghost" size="sm" onClick={onRetry}>Retry</Button>}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !hasError && ancestors.length === 0 && subtasks.length === 0 && createdTasks.length === 0 && (
|
||||
{!isLoading && !hasError && !createdFrom && ancestors.length === 0 && subtasks.length === 0 && createdTasks.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No tasks yet.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1159,7 +1159,7 @@ describe("inbox helpers", () => {
|
||||
creators: ["user:user-1"],
|
||||
labels: ["label-1"],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
workspaces: [],
|
||||
liveOnly: true,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: false,
|
||||
@@ -1210,7 +1210,7 @@ describe("inbox helpers", () => {
|
||||
creators: ["user:user-1"],
|
||||
labels: [],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: false,
|
||||
|
||||
+8
-2
@@ -217,7 +217,10 @@ export function loadInboxFilterPreferences(
|
||||
return {
|
||||
allCategoryFilter: normalizeInboxCategoryFilter(parsed.allCategoryFilter),
|
||||
allApprovalFilter: normalizeInboxApprovalFilter(parsed.allApprovalFilter),
|
||||
issueFilters: normalizeIssueFilterState(parsed.issueFilters),
|
||||
issueFilters: {
|
||||
...normalizeIssueFilterState(parsed.issueFilters),
|
||||
workspaces: [],
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
@@ -240,7 +243,10 @@ export function saveInboxFilterPreferences(
|
||||
JSON.stringify({
|
||||
allCategoryFilter: normalizeInboxCategoryFilter(preferences.allCategoryFilter),
|
||||
allApprovalFilter: normalizeInboxApprovalFilter(preferences.allApprovalFilter),
|
||||
issueFilters: normalizeIssueFilterState(preferences.issueFilters),
|
||||
issueFilters: {
|
||||
...normalizeIssueFilterState(preferences.issueFilters),
|
||||
workspaces: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routineOverviewCopyText } from "./routine-description-copy";
|
||||
|
||||
describe("routineOverviewCopyText", () => {
|
||||
it("copies the draft while the description is being edited", () => {
|
||||
expect(routineOverviewCopyText({
|
||||
editing: true,
|
||||
draft: "- draft line",
|
||||
saved: "- saved line",
|
||||
})).toBe("- draft line");
|
||||
});
|
||||
|
||||
it("copies the saved description when the overview is not being edited", () => {
|
||||
expect(routineOverviewCopyText({
|
||||
editing: false,
|
||||
draft: "- draft line",
|
||||
saved: "- saved line",
|
||||
})).toBe("- saved line");
|
||||
});
|
||||
|
||||
it("copies an empty string when there is no saved description", () => {
|
||||
expect(routineOverviewCopyText({
|
||||
editing: false,
|
||||
draft: "",
|
||||
saved: null,
|
||||
})).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Markdown copied from a routine Overview heading.
|
||||
* While the description editor is open, copy the draft. Otherwise copy the
|
||||
* saved description so it can be pasted into a routine on another instance.
|
||||
*/
|
||||
export function routineOverviewCopyText(input: {
|
||||
editing: boolean;
|
||||
draft: string;
|
||||
saved: string | null | undefined;
|
||||
}): string {
|
||||
return input.editing ? input.draft : input.saved ?? "";
|
||||
}
|
||||
@@ -221,7 +221,13 @@ function normalizeInboxCollectionViewState(value: unknown): StreamlinedInboxView
|
||||
? category
|
||||
: "everything",
|
||||
allApprovalFilter: approval === "actionable" || approval === "resolved" ? approval : "all",
|
||||
issueFilters: normalizeIssueFilterState(candidate.issueFilters),
|
||||
issueFilters: {
|
||||
...normalizeIssueFilterState(candidate.issueFilters),
|
||||
// The filter menu no longer offers Workspace. Drop a saved selection so
|
||||
// it cannot keep hiding tasks. A task-link workspace query is applied
|
||||
// by the task list after this load.
|
||||
workspaces: [],
|
||||
},
|
||||
showDateGroupSeparators: candidate.showDateGroupSeparators !== false,
|
||||
};
|
||||
}
|
||||
@@ -2482,7 +2488,6 @@ function StreamlinedInbox({
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
|
||||
presentation={streamlinedUiEnabled ? "streamlined" : "legacy"}
|
||||
/>
|
||||
<Popover>
|
||||
@@ -2588,7 +2593,6 @@ function StreamlinedInbox({
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
|
||||
presentation={streamlinedUiEnabled ? "streamlined" : "legacy"}
|
||||
inboxScopeFilters={tab === "all" ? {
|
||||
category: allCategoryFilter,
|
||||
|
||||
@@ -2471,7 +2471,12 @@ describe("IssueDetail", () => {
|
||||
|
||||
it("loads ancestors, subtask membership and created work independently and refreshes on issue activity", async () => {
|
||||
const ancestors = [{ id: "parent-task", identifier: "PAP-0", title: "Parent task", status: "in_progress" }] as Issue["ancestors"];
|
||||
const source = createIssue({ ancestors });
|
||||
const createdFrom: Issue["createdFrom"] = {
|
||||
issue: { id: "origin-task", identifier: "PAP-168", title: "Origin task", status: "in_progress" },
|
||||
run: { id: "run-1", agentId: "qa-agent" },
|
||||
agent: { id: "qa-agent", name: "Paperclip QA" },
|
||||
};
|
||||
const source = createIssue({ ancestors, createdFrom });
|
||||
const child = createIssue({ id: "manual-child", parentId: source.id, title: "Manual child" });
|
||||
const created = createIssue({ id: "created-task", parentId: null, title: "Created elsewhere" });
|
||||
mockIssuesApi.get.mockResolvedValue(source);
|
||||
@@ -2485,7 +2490,8 @@ describe("IssueDetail", () => {
|
||||
expect(taskProjection()?.content.props.subtasks.map((row: Issue) => row.id)).toEqual([child.id]);
|
||||
expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toEqual([created.id]);
|
||||
expect(taskProjection()?.content.props.ancestors).toEqual(ancestors);
|
||||
expect(taskProjection()?.count).toBe(3);
|
||||
expect(taskProjection()?.content.props.createdFrom).toEqual(createdFrom);
|
||||
expect(taskProjection()?.count).toBe(4);
|
||||
|
||||
const next = createIssue({ id: "new-created-task", parentId: source.id });
|
||||
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) =>
|
||||
@@ -2493,7 +2499,7 @@ describe("IssueDetail", () => {
|
||||
);
|
||||
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(source.companyId) }); });
|
||||
await flushReact();
|
||||
expect(taskProjection()?.count).toBe(4);
|
||||
expect(taskProjection()?.count).toBe(5);
|
||||
expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toContain(next.id);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { agentDetailHref } from "./agent-detail-navigation";
|
||||
import { ExecutionBlockerNotice } from "../components/ExecutionBlockerNotice";
|
||||
import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover";
|
||||
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
|
||||
import { IssueCreatedFromNote } from "@/components/task-detail/IssueCreatedFromNote";
|
||||
import { EmailThreadProvider } from "../components/EmailMessageCard";
|
||||
import { EmailTaskActivity } from "../components/EmailTaskActivity";
|
||||
import { TaskChatScrollNavigation, taskChatScrollEntry } from "@/components/task-chat/scroll-navigation";
|
||||
@@ -3654,11 +3655,17 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
const createdTasks = createdTasksQuery.data ?? EMPTY_ISSUES;
|
||||
const hasError = createdTasksQuery.isError || childIssuesError;
|
||||
return {
|
||||
count: new Set([...(issue?.ancestors ?? []), ...childIssues, ...createdTasks].map((task) => task.id)).size,
|
||||
count: new Set([
|
||||
...(issue?.createdFrom ? [issue.createdFrom.issue] : []),
|
||||
...(issue?.ancestors ?? []),
|
||||
...childIssues,
|
||||
...createdTasks,
|
||||
].map((task) => task.id)).size,
|
||||
hasError,
|
||||
content: (
|
||||
<TaskDetailTasksPanel
|
||||
ancestors={issue?.ancestors}
|
||||
createdFrom={issue?.createdFrom}
|
||||
issueLinkState={resolvedIssueDetailState ?? location.state}
|
||||
subtasks={childIssues}
|
||||
createdTasks={createdTasks}
|
||||
@@ -3675,6 +3682,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
}, [
|
||||
tasksTab,
|
||||
issue?.ancestors,
|
||||
issue?.createdFrom,
|
||||
resolvedIssueDetailState,
|
||||
location.state,
|
||||
streamlinedTaskDetailEnabled,
|
||||
@@ -7073,6 +7081,16 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
</span>
|
||||
</nav>
|
||||
) : null;
|
||||
// Creation provenance ("Created from PAP-168 by Paperclip QA"). Distinct from
|
||||
// the parent chain above; the streamlined Tasks tab renders it instead.
|
||||
const createdFromNote =
|
||||
!streamlinedTaskDetailEnabled && issue.createdFrom ? (
|
||||
<IssueCreatedFromNote
|
||||
createdFrom={issue.createdFrom}
|
||||
issueLinkState={resolvedIssueDetailState ?? location.state}
|
||||
className={shellSectionClass}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const issueStatusControl = (
|
||||
<StatusIcon
|
||||
@@ -7593,6 +7611,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
const taskChatThreadHeader = taskChatShellEnabled ? (
|
||||
<>
|
||||
{ancestorsNav}
|
||||
{createdFromNote}
|
||||
{issueHeaderBlock}
|
||||
{pluginOutletsBlock}
|
||||
</>
|
||||
@@ -7620,6 +7639,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
>
|
||||
{/* Parent chain breadcrumb (redesign: rendered inside the thread viewport) */}
|
||||
{taskChatShellEnabled ? null : ancestorsNav}
|
||||
{taskChatShellEnabled ? null : createdFromNote}
|
||||
|
||||
<ExternallyConnectedTaskBanner
|
||||
key={issue.id}
|
||||
|
||||
@@ -2353,7 +2353,6 @@ export function Inbox() {
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -2452,7 +2451,6 @@ export function Inbox() {
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import { routineOverviewCopyText } from "../lib/routine-description-copy";
|
||||
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
RoutineSectionPicker,
|
||||
} from "../components/RoutineSubSidebar";
|
||||
import { RoutineSaveBar } from "../components/RoutineSaveBar";
|
||||
import { RoutineSectionHeading } from "../components/RoutineSectionHeading";
|
||||
import {
|
||||
EDITABLE_SECTIONS,
|
||||
ROUTINE_SECTION_KEYS,
|
||||
@@ -879,9 +881,16 @@ export function RoutineDetail() {
|
||||
aria-labelledby="routine-section-title"
|
||||
className={isEditableSection ? "mx-auto w-full max-w-3xl" : "w-full"}
|
||||
>
|
||||
<h2 id="routine-section-title" className="mb-4 text-lg font-semibold">
|
||||
{SECTION_TITLES[section]}
|
||||
</h2>
|
||||
<RoutineSectionHeading
|
||||
title={SECTION_TITLES[section]}
|
||||
copyText={section === "overview"
|
||||
? routineOverviewCopyText({
|
||||
editing: true,
|
||||
draft: editDraft.description,
|
||||
saved: routine.description,
|
||||
})
|
||||
: null}
|
||||
/>
|
||||
|
||||
{section === "overview" && <OverviewSection />}
|
||||
{section === "triggers" && <TriggersSection />}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useToastActions } from "../context/ToastContext";
|
||||
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import { routineOverviewCopyText } from "../lib/routine-description-copy";
|
||||
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
@@ -37,6 +38,7 @@ import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RoutineOverview } from "../components/RoutineOverview";
|
||||
import { RoutineSectionHeading } from "../components/RoutineSectionHeading";
|
||||
import { RoutineSectionPicker, RoutineSubSidebar } from "../components/RoutineSubSidebar";
|
||||
import {
|
||||
isRoutineDetailView,
|
||||
@@ -899,9 +901,16 @@ export function RoutineDetail() {
|
||||
: "w-full"
|
||||
}
|
||||
>
|
||||
<h2 id="routine-section-title" className="mb-4 text-lg font-semibold">
|
||||
{SECTION_TITLES[section]}
|
||||
</h2>
|
||||
<RoutineSectionHeading
|
||||
title={SECTION_TITLES[section]}
|
||||
copyText={section === "overview"
|
||||
? routineOverviewCopyText({
|
||||
editing: overviewEditing,
|
||||
draft: editDraft.description,
|
||||
saved: routine.description,
|
||||
})
|
||||
: null}
|
||||
/>
|
||||
|
||||
{section === "overview" && (overviewEditing ? <OverviewSection /> : <RoutineOverview />)}
|
||||
{section === "triggers" && <TriggersSection />}
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects";
|
||||
import {
|
||||
storybookAgents,
|
||||
storybookExecutionWorkspaces,
|
||||
storybookIssueLabels,
|
||||
storybookIssues,
|
||||
storybookProjects,
|
||||
@@ -650,7 +649,6 @@ function FilterPopoverWithExternalChecked() {
|
||||
currentUserId="user-board"
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
workspaces={storybookExecutionWorkspaces.map((workspace) => ({ id: workspace.id, name: workspace.name }))}
|
||||
creators={[
|
||||
{ id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" },
|
||||
]}
|
||||
|
||||
@@ -596,7 +596,6 @@ function OpenFiltersPopover() {
|
||||
currentUserId="user-board"
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
workspaces={storybookExecutionWorkspaces.map((workspace) => ({ id: workspace.id, name: workspace.name }))}
|
||||
creators={[
|
||||
{ id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" },
|
||||
...storybookAgents.map((agent) => ({
|
||||
|
||||
Reference in new issue
Block a user