mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Merge origin/master into PAP-989 multi-user implementation
This commit is contained in:
commit
e63c38221d
212 files changed
+28789
-2332
No files matched your search
@@ -108,6 +108,21 @@ Notes:
|
||||
|
||||
## 7. Verification Before Hand-off
|
||||
|
||||
Default local/agent test path:
|
||||
|
||||
```sh
|
||||
pnpm test
|
||||
```
|
||||
|
||||
This is the cheap default and only runs the Vitest suite. Browser suites stay opt-in:
|
||||
|
||||
```sh
|
||||
pnpm test:e2e
|
||||
pnpm test:release-smoke
|
||||
```
|
||||
|
||||
Run the browser suites only when your change touches them or when you are explicitly verifying CI/release flows.
|
||||
|
||||
Run this full check before claiming done:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -233,11 +233,15 @@ pnpm dev:once # Full dev without file watching
|
||||
pnpm dev:server # Server only
|
||||
pnpm build # Build all
|
||||
pnpm typecheck # Type checking
|
||||
pnpm test:run # Run tests
|
||||
pnpm test # Cheap default test run (Vitest only)
|
||||
pnpm test:watch # Vitest watch mode
|
||||
pnpm test:e2e # Playwright browser suite
|
||||
pnpm db:generate # Generate DB migration
|
||||
pnpm db:migrate # Apply migrations
|
||||
```
|
||||
|
||||
`pnpm test` does not run Playwright. Browser suites stay separate and are typically run only when working on those flows or in CI.
|
||||
|
||||
See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
<br/>
|
||||
|
||||
+5
-1
@@ -233,11 +233,15 @@ pnpm dev:once # Full dev without file watching
|
||||
pnpm dev:server # Server only
|
||||
pnpm build # Build all
|
||||
pnpm typecheck # Type checking
|
||||
pnpm test:run # Run tests
|
||||
pnpm test # Cheap default test run (Vitest only)
|
||||
pnpm test:watch # Vitest watch mode
|
||||
pnpm test:e2e # Playwright browser suite
|
||||
pnpm db:generate # Generate DB migration
|
||||
pnpm db:migrate # Apply migrations
|
||||
```
|
||||
|
||||
`pnpm test` does not run Playwright. Browser suites stay separate and are typically run only when working on those flows or in CI.
|
||||
|
||||
See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
resolveWorktreeReseedTargetPaths,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeRepairCommand,
|
||||
worktreeInitCommand,
|
||||
worktreeMakeCommand,
|
||||
worktreeReseedCommand,
|
||||
@@ -937,6 +938,113 @@ describe("worktree helpers", () => {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("no-ops on the primary checkout unless --branch is provided", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-primary-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
process.chdir(repoRoot);
|
||||
await worktreeRepairCommand({});
|
||||
|
||||
expect(fs.existsSync(path.join(repoRoot, ".paperclip", "config.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(repoRoot, ".paperclip", "worktrees"))).toBe(false);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("repairs the current linked worktree when Paperclip metadata is missing", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-current-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", "repair-me");
|
||||
const sourceConfigPath = path.join(tempRoot, "source-config.json");
|
||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const worktreePaths = resolveWorktreeLocalPaths({
|
||||
cwd: worktreePath,
|
||||
homeDir: worktreeHome,
|
||||
instanceId: sanitizeWorktreeInstanceId(path.basename(worktreePath)),
|
||||
});
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", "-b", "repair-me", worktreePath, "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8");
|
||||
fs.mkdirSync(worktreePaths.instanceRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(worktreePaths.instanceRoot, "marker.txt"), "stale", "utf8");
|
||||
|
||||
process.chdir(worktreePath);
|
||||
await worktreeRepairCommand({
|
||||
fromConfig: sourceConfigPath,
|
||||
home: worktreeHome,
|
||||
noSeed: true,
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(worktreePaths.instanceRoot, "marker.txt"))).toBe(false);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("creates and repairs a missing branch worktree when --branch is provided", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-branch-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const sourceConfigPath = path.join(tempRoot, "source-config.json");
|
||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const originalCwd = process.cwd();
|
||||
const expectedWorktreePath = path.join(repoRoot, ".paperclip", "worktrees", "feature-repair-me");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8");
|
||||
|
||||
process.chdir(repoRoot);
|
||||
await worktreeRepairCommand({
|
||||
branch: "feature/repair-me",
|
||||
fromConfig: sourceConfigPath,
|
||||
home: worktreeHome,
|
||||
noSeed: true,
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(expectedWorktreePath, ".git"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", "config.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", ".env"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => {
|
||||
|
||||
+293
-16
@@ -130,6 +130,17 @@ type WorktreeReseedOptions = {
|
||||
allowLiveTarget?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeRepairOptions = {
|
||||
branch?: string;
|
||||
home?: string;
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
seedMode?: string;
|
||||
noSeed?: boolean;
|
||||
allowLiveTarget?: boolean;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
@@ -550,6 +561,46 @@ function detectGitBranchName(cwd: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function validateGitBranchName(cwd: string, branchName: string): string {
|
||||
const value = nonEmpty(branchName);
|
||||
if (!value) {
|
||||
throw new Error("Branch name is required.");
|
||||
}
|
||||
try {
|
||||
execFileSync("git", ["check-ref-format", "--branch", value], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid branch name "${branchName}": ${extractExecSyncErrorMessage(error) ?? String(error)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPrimaryGitWorktree(cwd: string): boolean {
|
||||
const workspace = detectGitWorkspaceInfo(cwd);
|
||||
return Boolean(workspace && workspace.gitDir === workspace.commonDir);
|
||||
}
|
||||
|
||||
function resolvePrimaryGitRepoRoot(cwd: string): string {
|
||||
const workspace = detectGitWorkspaceInfo(cwd);
|
||||
if (!workspace) {
|
||||
throw new Error("Current directory is not inside a git repository.");
|
||||
}
|
||||
if (workspace.gitDir === workspace.commonDir) {
|
||||
return workspace.root;
|
||||
}
|
||||
return path.resolve(workspace.commonDir, "..");
|
||||
}
|
||||
|
||||
function resolveRepairWorktreeDirName(branchName: string): string {
|
||||
const normalized = branchName.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-._]+|[-._]+$/g, "");
|
||||
return normalized || "worktree";
|
||||
}
|
||||
|
||||
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
||||
try {
|
||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
@@ -773,6 +824,21 @@ export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): Resol
|
||||
);
|
||||
}
|
||||
|
||||
function resolveWorktreeRepairSource(input: WorktreeRepairOptions): ResolvedWorktreeReseedSource {
|
||||
const fromConfig = nonEmpty(input.fromConfig);
|
||||
const fromDataDir = nonEmpty(input.fromDataDir);
|
||||
const fromInstance = nonEmpty(input.fromInstance) ?? "default";
|
||||
const configPath = resolveSourceConfigPath({
|
||||
fromConfig: fromConfig ?? undefined,
|
||||
fromDataDir: fromDataDir ?? undefined,
|
||||
fromInstance,
|
||||
});
|
||||
return {
|
||||
configPath,
|
||||
label: configPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorktreeReseedTargetPaths(input: {
|
||||
configPath: string;
|
||||
rootPath: string;
|
||||
@@ -794,6 +860,105 @@ export function resolveWorktreeReseedTargetPaths(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveExistingGitWorktree(selector: string, cwd: string): MergeSourceChoice | null {
|
||||
const trimmed = selector.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
const directPath = path.resolve(trimmed);
|
||||
if (existsSync(directPath)) {
|
||||
return {
|
||||
worktree: directPath,
|
||||
branch: null,
|
||||
branchLabel: path.basename(directPath),
|
||||
hasPaperclipConfig: existsSync(path.resolve(directPath, ".paperclip", "config.json")),
|
||||
isCurrent: directPath === path.resolve(cwd),
|
||||
};
|
||||
}
|
||||
|
||||
return toMergeSourceChoices(cwd).find((choice) =>
|
||||
choice.worktree === directPath
|
||||
|| path.basename(choice.worktree) === trimmed
|
||||
|| choice.branchLabel === trimmed
|
||||
|| choice.branch === trimmed,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
async function ensureRepairTargetWorktree(input: {
|
||||
selector?: string;
|
||||
seedMode: WorktreeSeedMode;
|
||||
opts: WorktreeRepairOptions;
|
||||
}): Promise<ResolvedWorktreeRepairTarget | null> {
|
||||
const cwd = process.cwd();
|
||||
const currentRoot = path.resolve(cwd);
|
||||
const currentConfigPath = path.resolve(currentRoot, ".paperclip", "config.json");
|
||||
|
||||
if (!input.selector) {
|
||||
if (isPrimaryGitWorktree(cwd)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rootPath: currentRoot,
|
||||
configPath: currentConfigPath,
|
||||
label: path.basename(currentRoot),
|
||||
branchName: detectGitBranchName(cwd),
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
const existing = resolveExistingGitWorktree(input.selector, cwd);
|
||||
if (existing) {
|
||||
return {
|
||||
rootPath: existing.worktree,
|
||||
configPath: path.resolve(existing.worktree, ".paperclip", "config.json"),
|
||||
label: existing.branchLabel,
|
||||
branchName: existing.branchLabel === "(detached)" ? null : existing.branchLabel,
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
const repoRoot = resolvePrimaryGitRepoRoot(cwd);
|
||||
const branchName = validateGitBranchName(repoRoot, input.selector);
|
||||
const targetPath = path.resolve(
|
||||
repoRoot,
|
||||
".paperclip",
|
||||
"worktrees",
|
||||
resolveRepairWorktreeDirName(branchName),
|
||||
);
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
throw new Error(`Target path already exists but is not a registered git worktree: ${targetPath}`);
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Creating git worktree for ${branchName}...`);
|
||||
try {
|
||||
execFileSync("git", resolveGitWorktreeAddArgs({
|
||||
branchName,
|
||||
targetPath,
|
||||
branchExists: localBranchExists(repoRoot, branchName),
|
||||
}), {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
spinner.stop(`Created git worktree at ${targetPath}.`);
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to create git worktree."));
|
||||
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
|
||||
installDependenciesBestEffort(targetPath);
|
||||
|
||||
return {
|
||||
rootPath: targetPath,
|
||||
configPath: path.resolve(targetPath, ".paperclip", "config.json"),
|
||||
label: branchName,
|
||||
branchName,
|
||||
created: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
||||
if (config.database.mode === "postgres") {
|
||||
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
||||
@@ -1205,18 +1370,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
||||
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
|
||||
const installSpinner = p.spinner();
|
||||
installSpinner.start("Installing dependencies...");
|
||||
try {
|
||||
execFileSync("pnpm", ["install"], {
|
||||
cwd: targetPath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
installSpinner.stop("Installed dependencies.");
|
||||
} catch (error) {
|
||||
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
|
||||
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
installDependenciesBestEffort(targetPath);
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
@@ -1233,6 +1387,21 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
||||
}
|
||||
}
|
||||
|
||||
function installDependenciesBestEffort(targetPath: string): void {
|
||||
const installSpinner = p.spinner();
|
||||
installSpinner.start("Installing dependencies...");
|
||||
try {
|
||||
execFileSync("pnpm", ["install"], {
|
||||
cwd: targetPath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
installSpinner.stop("Installed dependencies.");
|
||||
} catch (error) {
|
||||
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
|
||||
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
}
|
||||
|
||||
type WorktreeCleanupOptions = {
|
||||
instance?: string;
|
||||
home?: string;
|
||||
@@ -1266,6 +1435,14 @@ type ResolvedWorktreeReseedSource = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ResolvedWorktreeRepairTarget = {
|
||||
rootPath: string;
|
||||
configPath: string;
|
||||
label: string;
|
||||
branchName: string | null;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
|
||||
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
@@ -2707,10 +2884,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
||||
|
||||
async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
|
||||
const seedMode = opts.seedMode ?? "full";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
@@ -2790,6 +2964,96 @@ export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
||||
await runWorktreeReseed(opts);
|
||||
}
|
||||
|
||||
export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree repair ")));
|
||||
|
||||
const seedMode = opts.seedMode ?? "minimal";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
}
|
||||
|
||||
const target = await ensureRepairTargetWorktree({
|
||||
selector: nonEmpty(opts.branch) ?? undefined,
|
||||
seedMode,
|
||||
opts,
|
||||
});
|
||||
if (!target) {
|
||||
p.log.warn("Current checkout is the primary repo worktree. Pass --branch to create or repair a linked worktree.");
|
||||
p.outro(pc.yellow("No worktree repaired."));
|
||||
return;
|
||||
}
|
||||
|
||||
const source = resolveWorktreeRepairSource(opts);
|
||||
if (!existsSync(source.configPath)) {
|
||||
throw new Error(`Source config not found at ${source.configPath}.`);
|
||||
}
|
||||
if (path.resolve(source.configPath) === path.resolve(target.configPath)) {
|
||||
throw new Error("Source and target Paperclip configs are the same. Use --from-config/--from-instance to point repair at a different source.");
|
||||
}
|
||||
|
||||
const targetConfig = existsSync(target.configPath) ? readConfig(target.configPath) : null;
|
||||
const targetEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(target.configPath));
|
||||
const targetHasWorktreeEnv = Boolean(
|
||||
nonEmpty(targetEnvEntries.PAPERCLIP_HOME) && nonEmpty(targetEnvEntries.PAPERCLIP_INSTANCE_ID),
|
||||
);
|
||||
|
||||
if (targetConfig && targetHasWorktreeEnv && opts.noSeed) {
|
||||
p.log.message(pc.dim(`Target ${target.label} already has worktree-local config/env. Skipping reseed because --no-seed was passed.`));
|
||||
p.outro(pc.green(`Worktree metadata already looks healthy for ${target.label}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetConfig && targetHasWorktreeEnv) {
|
||||
await runWorktreeReseed({
|
||||
fromConfig: source.configPath,
|
||||
to: target.rootPath,
|
||||
seedMode,
|
||||
yes: true,
|
||||
allowLiveTarget: opts.allowLiveTarget,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const repairInstanceId = sanitizeWorktreeInstanceId(path.basename(target.rootPath));
|
||||
const repairPaths = resolveWorktreeLocalPaths({
|
||||
cwd: target.rootPath,
|
||||
homeDir: resolveWorktreeHome(opts.home),
|
||||
instanceId: repairInstanceId,
|
||||
});
|
||||
const runningTargetPid = readRunningPostmasterPid(path.resolve(repairPaths.embeddedPostgresDataDir, "postmaster.pid"));
|
||||
if (runningTargetPid && !opts.allowLiveTarget) {
|
||||
throw new Error(
|
||||
`Target worktree database appears to be running (pid ${runningTargetPid}). Stop Paperclip in ${target.rootPath} before repairing, or re-run with --allow-live-target if you want to override this guard.`,
|
||||
);
|
||||
}
|
||||
if (runningTargetPid && opts.allowLiveTarget) {
|
||||
p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`);
|
||||
}
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(target.rootPath);
|
||||
await runWorktreeInit({
|
||||
home: opts.home,
|
||||
fromConfig: source.configPath,
|
||||
fromDataDir: opts.fromDataDir,
|
||||
fromInstance: opts.fromInstance,
|
||||
seed: opts.noSeed ? false : true,
|
||||
seedMode,
|
||||
force: true,
|
||||
});
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorktreeCommands(program: Command): void {
|
||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||
|
||||
@@ -2865,6 +3129,19 @@ export function registerWorktreeCommands(program: Command): void {
|
||||
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
||||
.action(worktreeReseedCommand);
|
||||
|
||||
worktree
|
||||
.command("repair")
|
||||
.description("Create or repair a linked worktree-local Paperclip instance without touching the primary checkout")
|
||||
.option("--branch <name>", "Existing branch/worktree selector to repair, or a branch name to create under .paperclip/worktrees")
|
||||
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config (default: default)")
|
||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||
.option("--no-seed", "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", false)
|
||||
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
||||
.action(worktreeRepairCommand);
|
||||
|
||||
program
|
||||
.command("worktree:cleanup")
|
||||
.description("Safely remove a worktree, its branch, and its isolated instance data")
|
||||
|
||||
+51
-1
@@ -79,6 +79,29 @@ Allow additional private hostnames (for example custom Tailscale hostnames):
|
||||
pnpm paperclipai allowed-hostname dotta-macbook-pro
|
||||
```
|
||||
|
||||
## Test Commands
|
||||
|
||||
Use the cheap local default unless you are specifically working on browser flows:
|
||||
|
||||
```sh
|
||||
pnpm test
|
||||
```
|
||||
|
||||
`pnpm test` runs the Vitest suite only. For interactive Vitest watch mode use:
|
||||
|
||||
```sh
|
||||
pnpm test:watch
|
||||
```
|
||||
|
||||
Browser suites stay separate:
|
||||
|
||||
```sh
|
||||
pnpm test:e2e
|
||||
pnpm test:release-smoke
|
||||
```
|
||||
|
||||
These browser suites are intended for targeted local verification and CI, not the default agent/human test command.
|
||||
|
||||
## One-Command Local Run
|
||||
|
||||
For a first-time local install, you can bootstrap and run in one command:
|
||||
@@ -239,7 +262,7 @@ paperclipai worktree init --force
|
||||
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install:
|
||||
|
||||
```sh
|
||||
cd ~/.paperclip/worktrees/PAP-884-ai-commits-component
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component
|
||||
pnpm paperclipai worktree init --force --seed-mode minimal \
|
||||
--name PAP-884-ai-commits-component \
|
||||
--from-config ~/.paperclip/instances/default/config.json
|
||||
@@ -247,6 +270,33 @@ pnpm paperclipai worktree init --force --seed-mode minimal \
|
||||
|
||||
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
|
||||
|
||||
For an already-created worktree where you want the CLI to decide whether to rebuild missing worktree metadata or just reseed the isolated DB, use `worktree repair`.
|
||||
|
||||
**`pnpm paperclipai worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.paperclip/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--branch <name>` | Existing branch/worktree selector to repair, or a branch name to create under `.paperclip/worktrees` |
|
||||
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
|
||||
| `--from-config <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id when deriving the source config (default: `default`) |
|
||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
|
||||
| `--no-seed` | Repair metadata only when bootstrapping a missing worktree config |
|
||||
| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
# From inside a linked worktree, rebuild missing .paperclip metadata and reseed it from the default instance.
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
pnpm paperclipai worktree repair
|
||||
|
||||
# From the primary checkout, create or repair a linked worktree for a branch under .paperclip/worktrees/.
|
||||
cd /path/to/paperclip
|
||||
pnpm paperclipai worktree repair --branch PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
```
|
||||
|
||||
For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely.
|
||||
|
||||
**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
|
||||
|
||||
@@ -395,6 +395,8 @@ Side effects:
|
||||
- entering `done` sets `completed_at`
|
||||
- entering `cancelled` sets `cancelled_at`
|
||||
|
||||
Detailed ownership, execution, blocker, and crash-recovery semantics are documented in `doc/execution-semantics.md`.
|
||||
|
||||
## 8.3 Approval Status
|
||||
|
||||
- `pending -> approved | rejected | cancelled`
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
# Execution Semantics
|
||||
|
||||
Status: Current implementation guide
|
||||
Date: 2026-04-13
|
||||
Audience: Product and engineering
|
||||
|
||||
This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships.
|
||||
|
||||
`doc/SPEC-implementation.md` remains the V1 contract. This document is the detailed execution model behind that contract.
|
||||
|
||||
## 1. Core Model
|
||||
|
||||
Paperclip separates four concepts that are easy to blur together:
|
||||
|
||||
1. structure: parent/sub-issue relationships
|
||||
2. dependency: blocker relationships
|
||||
3. ownership: who is responsible for the issue now
|
||||
4. execution: whether the control plane currently has a live path to move the issue forward
|
||||
|
||||
The system works best when those are kept separate.
|
||||
|
||||
## 2. Assignee Semantics
|
||||
|
||||
An issue has at most one assignee.
|
||||
|
||||
- `assigneeAgentId` means the issue is owned by an agent
|
||||
- `assigneeUserId` means the issue is owned by a human board user
|
||||
- both cannot be set at the same time
|
||||
|
||||
This is a hard invariant. Paperclip is single-assignee by design.
|
||||
|
||||
## 3. Status Semantics
|
||||
|
||||
Paperclip issue statuses are not just UI labels. They imply different expectations about ownership and execution.
|
||||
|
||||
### `backlog`
|
||||
|
||||
The issue is not ready for active work.
|
||||
|
||||
- no execution expectation
|
||||
- no pickup expectation
|
||||
- safe resting state for future work
|
||||
|
||||
### `todo`
|
||||
|
||||
The issue is actionable but not actively claimed.
|
||||
|
||||
- it may be assigned or unassigned
|
||||
- no checkout/execution lock is required yet
|
||||
- for agent-assigned work, Paperclip may still need a wake path to ensure the assignee actually sees it
|
||||
|
||||
### `in_progress`
|
||||
|
||||
The issue is actively owned work.
|
||||
|
||||
- requires an assignee
|
||||
- for agent-owned issues, this is a strict execution-backed state
|
||||
- for user-owned issues, this is a human ownership state and is not backed by heartbeat execution
|
||||
|
||||
For agent-owned issues, `in_progress` should not be allowed to become a silent dead state.
|
||||
|
||||
### `blocked`
|
||||
|
||||
The issue cannot proceed until something external changes.
|
||||
|
||||
This is the right state for:
|
||||
|
||||
- waiting on another issue
|
||||
- waiting on a human decision
|
||||
- waiting on an external dependency or system
|
||||
- work that automatic recovery could not safely continue
|
||||
|
||||
### `in_review`
|
||||
|
||||
Execution work is paused because the next move belongs to a reviewer or approver, not the current executor.
|
||||
|
||||
### `done`
|
||||
|
||||
The work is complete and terminal.
|
||||
|
||||
### `cancelled`
|
||||
|
||||
The work will not continue and is terminal.
|
||||
|
||||
## 4. Agent-Owned vs User-Owned Execution
|
||||
|
||||
The execution model differs depending on assignee type.
|
||||
|
||||
### Agent-owned issues
|
||||
|
||||
Agent-owned issues are part of the control plane's execution loop.
|
||||
|
||||
- Paperclip can wake the assignee
|
||||
- Paperclip can track runs linked to the issue
|
||||
- Paperclip can recover some lost execution state after crashes/restarts
|
||||
|
||||
### User-owned issues
|
||||
|
||||
User-owned issues are not executed by the heartbeat scheduler.
|
||||
|
||||
- Paperclip can track the ownership and status
|
||||
- Paperclip cannot rely on heartbeat/run semantics to keep them moving
|
||||
- stranded-work reconciliation does not apply to them
|
||||
|
||||
This is why `in_progress` can be strict for agents without forcing the same runtime rules onto human-held work.
|
||||
|
||||
## 5. Checkout and Active Execution
|
||||
|
||||
Checkout is the bridge from issue ownership to active agent execution.
|
||||
|
||||
- checkout is required to move an issue into agent-owned `in_progress`
|
||||
- `checkoutRunId` represents issue-ownership lock for the current agent run
|
||||
- `executionRunId` represents the currently active execution path for the issue
|
||||
|
||||
These are related but not identical:
|
||||
|
||||
- `checkoutRunId` answers who currently owns execution rights for the issue
|
||||
- `executionRunId` answers which run is actually live right now
|
||||
|
||||
Paperclip already clears stale execution locks and can adopt some stale checkout locks when the original run is gone.
|
||||
|
||||
## 6. Parent/Sub-Issue vs Blockers
|
||||
|
||||
Paperclip uses two different relationships for different jobs.
|
||||
|
||||
### Parent/Sub-Issue (`parentId`)
|
||||
|
||||
This is structural.
|
||||
|
||||
Use it for:
|
||||
|
||||
- work breakdown
|
||||
- rollup context
|
||||
- explaining why a child issue exists
|
||||
- waking the parent assignee when all direct children become terminal
|
||||
|
||||
Do not treat `parentId` as execution dependency by itself.
|
||||
|
||||
### Blockers (`blockedByIssueIds`)
|
||||
|
||||
This is dependency semantics.
|
||||
|
||||
Use it for:
|
||||
|
||||
- \"this issue cannot continue until that issue changes state\"
|
||||
- explicit waiting relationships
|
||||
- automatic wakeups when all blockers resolve
|
||||
|
||||
If a parent is truly waiting on a child, model that with blockers. Do not rely on the parent/child relationship alone.
|
||||
|
||||
## 7. Consistent Execution Path Rules
|
||||
|
||||
For agent-assigned, non-terminal, actionable issues, Paperclip should not leave work in a state where nobody is working it and nothing will wake it.
|
||||
|
||||
The relevant execution path depends on status.
|
||||
|
||||
### Agent-assigned `todo`
|
||||
|
||||
This is dispatch state: ready to start, not yet actively claimed.
|
||||
|
||||
A healthy dispatch state means at least one of these is true:
|
||||
|
||||
- the issue already has a queued/running wake path
|
||||
- the issue is intentionally resting in `todo` after a successful agent heartbeat, not after an interrupted dispatch
|
||||
- the issue has been explicitly surfaced as stranded
|
||||
|
||||
### Agent-assigned `in_progress`
|
||||
|
||||
This is active-work state.
|
||||
|
||||
A healthy active-work state means at least one of these is true:
|
||||
|
||||
- there is an active run for the issue
|
||||
- there is already a queued continuation wake
|
||||
- the issue has been explicitly surfaced as stranded
|
||||
|
||||
## 8. Crash and Restart Recovery
|
||||
|
||||
Paperclip now treats crash/restart recovery as a stranded-assigned-work problem, not just a stranded-run problem.
|
||||
|
||||
There are two distinct failure modes.
|
||||
|
||||
### 8.1 Stranded assigned `todo`
|
||||
|
||||
Example:
|
||||
|
||||
- issue is assigned to an agent
|
||||
- status is `todo`
|
||||
- the original wake/run died during or after dispatch
|
||||
- after restart there is no queued wake and nothing picks the issue back up
|
||||
|
||||
Recovery rule:
|
||||
|
||||
- if the latest issue-linked run failed/timed out/cancelled and no live execution path remains, Paperclip queues one automatic assignment recovery wake
|
||||
- if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
|
||||
|
||||
This is a dispatch recovery, not a continuation recovery.
|
||||
|
||||
### 8.2 Stranded assigned `in_progress`
|
||||
|
||||
Example:
|
||||
|
||||
- issue is assigned to an agent
|
||||
- status is `in_progress`
|
||||
- the live run disappeared
|
||||
- after restart there is no active run and no queued continuation
|
||||
|
||||
Recovery rule:
|
||||
|
||||
- Paperclip queues one automatic continuation wake
|
||||
- if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
|
||||
|
||||
This is an active-work continuity recovery.
|
||||
|
||||
## 9. Startup and Periodic Reconciliation
|
||||
|
||||
Startup recovery and periodic recovery are different from normal wakeup delivery.
|
||||
|
||||
On startup and on the periodic recovery loop, Paperclip now does three things in sequence:
|
||||
|
||||
1. reap orphaned `running` runs
|
||||
2. resume persisted `queued` runs
|
||||
3. reconcile stranded assigned work
|
||||
|
||||
That last step is what closes the gap where issue state survives a crash but the wake/run path does not.
|
||||
|
||||
## 10. What This Does Not Mean
|
||||
|
||||
These semantics do not change V1 into an auto-reassignment system.
|
||||
|
||||
Paperclip still does not:
|
||||
|
||||
- automatically reassign work to a different agent
|
||||
- infer dependency semantics from `parentId` alone
|
||||
- treat human-held work as heartbeat-managed execution
|
||||
|
||||
The recovery model is intentionally conservative:
|
||||
|
||||
- preserve ownership
|
||||
- retry once when the control plane lost execution continuity
|
||||
- escalate visibly when the system cannot safely keep going
|
||||
|
||||
## 11. Practical Interpretation
|
||||
|
||||
For a board operator, the intended meaning is:
|
||||
|
||||
- agent-owned `in_progress` should mean \"this is live work or clearly surfaced as a problem\"
|
||||
- agent-owned `todo` should not stay assigned forever after a crash with no remaining wake path
|
||||
- parent/sub-issue explains structure
|
||||
- blockers explain waiting
|
||||
|
||||
That is the execution contract Paperclip should present to operators.
|
||||
@@ -0,0 +1,382 @@
|
||||
# VS Code Task Interoperability Plan
|
||||
|
||||
Status: planning only, no code changes
|
||||
Date: 2026-04-12
|
||||
Related issue: `PAP-1377`
|
||||
|
||||
## Summary
|
||||
|
||||
Paperclip should not replace its workspace runtime service model with VS Code tasks.
|
||||
It should add a narrow interoperability layer that can discover and adopt supported entries from `.vscode/tasks.json`.
|
||||
|
||||
The core product model should stay:
|
||||
|
||||
- Paperclip owns long-running workspace services and their desired state
|
||||
- Paperclip shows operators exactly which named thing they are starting or stopping
|
||||
- Paperclip distinguishes long-running services from one-shot jobs
|
||||
|
||||
VS Code tasks should be treated as:
|
||||
|
||||
- an import/discovery format for workspace commands
|
||||
- a convenience for repos that already maintain `tasks.json`
|
||||
- a partial compatibility layer, not a full execution model
|
||||
|
||||
## Current State
|
||||
|
||||
The current implementation is already service-oriented:
|
||||
|
||||
- project workspaces and execution workspaces can store `workspaceRuntime` config plus `desiredState` and per-service `serviceStates`
|
||||
- the UI renders one control row per configured service and persists start/stop intent
|
||||
- the backend supervises long-running local processes, reuses eligible services, and restores desired services on startup
|
||||
|
||||
Relevant files:
|
||||
|
||||
- `packages/shared/src/types/workspace-runtime.ts`
|
||||
- `server/src/services/workspace-runtime.ts`
|
||||
- `server/src/services/project-workspace-runtime-config.ts`
|
||||
- `ui/src/components/WorkspaceRuntimeControls.tsx`
|
||||
- `ui/src/pages/ProjectWorkspaceDetail.tsx`
|
||||
- `ui/src/pages/ExecutionWorkspaceDetail.tsx`
|
||||
|
||||
This is directionally correct for Paperclip because it gives the control plane an explicit model for service lifecycle, health, reuse, and restart behavior.
|
||||
|
||||
## Problem To Solve
|
||||
|
||||
The current UX is still too raw:
|
||||
|
||||
- operators have to hand-author runtime JSON
|
||||
- a workspace can have multiple attached services, but the higher-level intent is not obvious
|
||||
- start/stop controls are visible in multiple places, which makes it easy to lose track of what is being controlled
|
||||
- there is no interoperability with repos that already define useful local workflows in `.vscode/tasks.json`
|
||||
|
||||
The issue is not that services are the wrong abstraction.
|
||||
The issue is that the configuration surface is too low-level and Paperclip does not yet leverage existing workspace metadata.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Keep Paperclip runtime services as the source of truth for service supervision.
|
||||
Add a new workspace command model above the raw JSON layer, with VS Code task discovery as one input.
|
||||
|
||||
The product model should become:
|
||||
|
||||
1. `Workspace command`
|
||||
A named runnable thing attached to a workspace.
|
||||
|
||||
2. `Workspace service`
|
||||
A workspace command that is expected to stay alive and be supervised.
|
||||
|
||||
3. `Workspace job`
|
||||
A workspace command that runs once and exits.
|
||||
|
||||
4. `Runtime service instance`
|
||||
The live process record that already exists today in Paperclip.
|
||||
|
||||
In that model, VS Code tasks are a way to populate workspace commands.
|
||||
Only commands that map cleanly to Paperclip service or job semantics should become runnable in Paperclip.
|
||||
|
||||
## Why Not Fully Adopt VS Code Tasks
|
||||
|
||||
VS Code tasks are broader than Paperclip runtime services.
|
||||
They include shell/process tasks, compound tasks, background/watch tasks, presentation settings, extension/task-provider types, variable substitution, and problem-matcher-driven lifecycle.
|
||||
|
||||
That creates a bad fit if Paperclip tries to use `tasks.json` as its only runtime model:
|
||||
|
||||
- many tasks are one-shot jobs, not long-running services
|
||||
- some tasks depend on VS Code task providers or editor-only variable resolution
|
||||
- compound task graphs are useful, but they are not the same thing as a supervised service
|
||||
- problem matcher readiness is useful metadata, but it is not enough to replace Paperclip's persisted service lifecycle model
|
||||
|
||||
The right boundary is interoperability, not replacement.
|
||||
|
||||
## Interoperability Contract
|
||||
|
||||
Paperclip should support a conservative subset of VS Code tasks and clearly mark unsupported entries.
|
||||
|
||||
### Supported in phase 1
|
||||
|
||||
- `shell` and `process` tasks with a concrete command Paperclip can resolve
|
||||
- optional task `options.cwd`
|
||||
- optional task environment values that can be flattened safely
|
||||
- task labels and detail text for naming and display
|
||||
- `dependsOn` for import-time expansion or display-only dependency hints
|
||||
- background/watch-oriented tasks that can reasonably be treated as long-running services
|
||||
|
||||
### Maybe supported in later phases
|
||||
|
||||
- grouping and default task metadata for better UX
|
||||
- selected variable substitution when Paperclip can resolve it safely from workspace context
|
||||
- mapping task metadata into Paperclip readiness/expose hints
|
||||
- limited compound-task launch flows
|
||||
|
||||
### Not supported initially
|
||||
|
||||
- extension-provided task types Paperclip cannot execute directly
|
||||
- arbitrary VS Code variable substitution semantics
|
||||
- problem matcher parsing as the main source of service health
|
||||
- full parity with VS Code task execution behavior
|
||||
|
||||
## Long-Running Service Detection
|
||||
|
||||
Paperclip needs an explicit classification layer instead of assuming every VS Code task is a service.
|
||||
|
||||
Recommended classification:
|
||||
|
||||
- `service`
|
||||
Explicitly marked by Paperclip metadata, or confidently inferred from background/watch task semantics
|
||||
|
||||
- `job`
|
||||
One-shot command expected to exit
|
||||
|
||||
- `unsupported`
|
||||
Present in `tasks.json`, but not safely runnable by Paperclip
|
||||
|
||||
The important product decision is that service classification must be visible and editable by the operator.
|
||||
Inference can help, but it should not be the only source of truth.
|
||||
|
||||
## Proposed Product Shape
|
||||
|
||||
### 1. Replace raw-first editing with command-first editing
|
||||
|
||||
Project and execution workspace pages should stop making raw runtime JSON the primary editing surface.
|
||||
|
||||
Default UI should show:
|
||||
|
||||
- workspace commands
|
||||
- command type: service or job
|
||||
- source: Paperclip or VS Code
|
||||
- exact command and cwd
|
||||
- current state for services
|
||||
- explicit start, stop, restart, and run-now actions
|
||||
|
||||
Raw JSON should remain available behind an advanced section.
|
||||
|
||||
### 2. Add VS Code task discovery on workspaces
|
||||
|
||||
For a workspace with `cwd`, Paperclip should look for `.vscode/tasks.json`.
|
||||
|
||||
The workspace UI should show:
|
||||
|
||||
- whether a `tasks.json` file was found
|
||||
- last parse time
|
||||
- supported commands discovered
|
||||
- unsupported tasks with reasons
|
||||
- whether commands are inherited into execution workspaces
|
||||
|
||||
### 3. Make the controlled thing explicit
|
||||
|
||||
Start and stop UI should always name the exact entry being controlled.
|
||||
|
||||
Examples:
|
||||
|
||||
- `Start web`
|
||||
- `Stop api`
|
||||
- `Run db:migrate`
|
||||
|
||||
Avoid generic workspace-level labels when multiple commands exist.
|
||||
|
||||
### 4. Separate services from jobs in the UI
|
||||
|
||||
Do not mix one-shot jobs and long-running services into one undifferentiated list.
|
||||
|
||||
Recommended sections:
|
||||
|
||||
- `Services`
|
||||
- `Jobs`
|
||||
- `Unsupported imported tasks`
|
||||
|
||||
That resolves the ambiguity called out in the issue.
|
||||
|
||||
## Data Model Direction
|
||||
|
||||
Do not replace `workspaceRuntime` immediately.
|
||||
Instead add a higher-level representation that can compile down to the existing runtime-service machinery.
|
||||
|
||||
Suggested workspace metadata shape:
|
||||
|
||||
```ts
|
||||
type WorkspaceCommandSource =
|
||||
| { type: "paperclip" }
|
||||
| { type: "vscode_task"; taskLabel: string; taskPath: ".vscode/tasks.json" };
|
||||
|
||||
type WorkspaceCommandKind = "service" | "job";
|
||||
|
||||
type WorkspaceCommandDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: WorkspaceCommandKind;
|
||||
source: WorkspaceCommandSource;
|
||||
command: string | null;
|
||||
cwd: string | null;
|
||||
env?: Record<string, string> | null;
|
||||
autoStart?: boolean;
|
||||
serviceConfig?: {
|
||||
lifecycle?: "shared" | "ephemeral";
|
||||
reuseScope?: "project_workspace" | "execution_workspace" | "run";
|
||||
readiness?: Record<string, unknown> | null;
|
||||
expose?: Record<string, unknown> | null;
|
||||
} | null;
|
||||
importWarnings?: string[];
|
||||
disabledReason?: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
`workspaceRuntime` can then become a derived or advanced representation for service-type commands until the rest of the system is migrated.
|
||||
|
||||
## VS Code Mapping Rules
|
||||
|
||||
Paperclip should map imported tasks with explicit, documented rules.
|
||||
|
||||
Recommended rules:
|
||||
|
||||
1. A task becomes a `job` by default.
|
||||
2. A task becomes a `service` only when:
|
||||
- Paperclip metadata marks it as a service, or
|
||||
- the task clearly represents a background/watch process and the operator confirms the classification.
|
||||
3. Unsupported tasks stay visible but disabled.
|
||||
4. Task labels become default command names.
|
||||
5. `dependsOn` is preserved as metadata, not silently flattened into hidden behavior.
|
||||
|
||||
Paperclip-specific metadata can live in a namespaced field on the imported task definition, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "web",
|
||||
"type": "shell",
|
||||
"command": "pnpm dev",
|
||||
"isBackground": true,
|
||||
"paperclip": {
|
||||
"kind": "service",
|
||||
"readiness": {
|
||||
"type": "http",
|
||||
"urlTemplate": "http://127.0.0.1:${port}"
|
||||
},
|
||||
"expose": {
|
||||
"type": "url",
|
||||
"urlTemplate": "http://127.0.0.1:${port}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That gives us interoperability without depending on VS Code-only semantics for service readiness and exposure.
|
||||
|
||||
## Execution Policy
|
||||
|
||||
Project workspaces should be the main place where imported commands are discovered and curated.
|
||||
Execution workspaces should inherit that curated command set by default, with optional issue-level overrides.
|
||||
|
||||
Recommended precedence:
|
||||
|
||||
1. execution workspace override
|
||||
2. project workspace command set
|
||||
3. imported VS Code tasks from the linked workspace
|
||||
4. advanced raw runtime fallback
|
||||
|
||||
This matches the existing direction in `doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md`.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Discovery and read-only visibility
|
||||
|
||||
Goal:
|
||||
show imported VS Code tasks in the workspace UI without changing runtime behavior.
|
||||
|
||||
Work:
|
||||
|
||||
- parse `.vscode/tasks.json` for project workspaces with local `cwd`
|
||||
- derive a list of candidate commands plus unsupported items
|
||||
- show source, label, command, cwd, and classification
|
||||
- show parse warnings and unsupported reasons
|
||||
|
||||
Success condition:
|
||||
an operator can see what Paperclip would import and why.
|
||||
|
||||
### Phase 2: Command model and explicit classification
|
||||
|
||||
Goal:
|
||||
introduce a first-class workspace command layer above raw runtime JSON.
|
||||
|
||||
Work:
|
||||
|
||||
- add a persisted command definition model in workspace metadata or a dedicated table
|
||||
- allow operator edits to imported command classification
|
||||
- separate `service` and `job` in UI
|
||||
- keep existing runtime-service storage for live supervised processes
|
||||
|
||||
Success condition:
|
||||
the workspace UI is command-first, and raw runtime JSON is advanced-only.
|
||||
|
||||
### Phase 3: Service execution backed by existing runtime supervisor
|
||||
|
||||
Goal:
|
||||
run supported imported service commands through the current Paperclip supervisor.
|
||||
|
||||
Work:
|
||||
|
||||
- compile service commands into the existing runtime service start/stop path
|
||||
- persist desired state per named command
|
||||
- keep startup restoration behavior for service commands
|
||||
- make the active command name explicit everywhere control actions appear
|
||||
|
||||
Success condition:
|
||||
imported service commands behave like native Paperclip services once adopted.
|
||||
|
||||
### Phase 4: Job execution and optional dependency handling
|
||||
|
||||
Goal:
|
||||
support one-shot imported commands without pretending they are services.
|
||||
|
||||
Work:
|
||||
|
||||
- add `Run` actions for jobs
|
||||
- record output in workspace operations
|
||||
- optionally support simple `dependsOn` execution for jobs with clear logging
|
||||
|
||||
Success condition:
|
||||
one-shot tasks are runnable, but they are not mixed into the service lifecycle model.
|
||||
|
||||
### Phase 5: Adapter and execution workspace integration
|
||||
|
||||
Goal:
|
||||
let agents and issue-scoped workspaces consume the curated command model consistently.
|
||||
|
||||
Work:
|
||||
|
||||
- expose inherited workspace commands to execution workspaces
|
||||
- allow issue-level selection of a default service command when relevant
|
||||
- make service selection explicit in issue and workspace views
|
||||
|
||||
Success condition:
|
||||
agents, operators, and workspaces all refer to the same named commands.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- full VS Code task-runner parity
|
||||
- support for every VS Code task type
|
||||
- removal of Paperclip's own runtime supervision model
|
||||
- editor-dependent execution semantics inside the control plane
|
||||
|
||||
## Risks
|
||||
|
||||
- overfitting Paperclip to VS Code and making the model worse for non-VS-Code repos
|
||||
- misclassifying watch tasks as durable services
|
||||
- hiding too much detail and making debugging harder
|
||||
- allowing imported task graphs to become implicit magic
|
||||
|
||||
These risks are manageable if the import layer stays explicit, conservative, and operator-editable.
|
||||
|
||||
## Decision
|
||||
|
||||
Paperclip should adopt VS Code tasks as an optional workspace command source, not as the canonical runtime model.
|
||||
|
||||
The main UX change should be:
|
||||
|
||||
- move from raw runtime JSON to named workspace commands
|
||||
- separate services from jobs
|
||||
- make the exact controlled command explicit
|
||||
- let `.vscode/tasks.json` pre-populate those commands when available
|
||||
|
||||
## External References
|
||||
|
||||
- VS Code tasks documentation: https://code.visualstudio.com/docs/debugtest/tasks
|
||||
- Existing Paperclip workspace plan: `doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md`
|
||||
@@ -13,6 +13,8 @@ GET /api/companies/{companyId}/agents
|
||||
|
||||
Returns all agents in the company.
|
||||
|
||||
This route does not accept query filters. Unsupported query parameters return `400`.
|
||||
|
||||
## Get Agent
|
||||
|
||||
```
|
||||
|
||||
@@ -66,6 +66,8 @@ The optional `comment` field adds a comment in the same call.
|
||||
|
||||
Updatable fields: `title`, `description`, `status`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`.
|
||||
|
||||
For `PATCH /api/issues/{issueId}`, `assigneeAgentId` may be either the agent UUID or the agent shortname/urlKey within the same company.
|
||||
|
||||
## Checkout (Claim Task)
|
||||
|
||||
```
|
||||
|
||||
@@ -5,22 +5,28 @@ summary: How project runtime configuration, execution workspaces, and issue runs
|
||||
|
||||
This guide documents the intended runtime model for projects, execution workspaces, and issue runs in Paperclip.
|
||||
|
||||
Paperclip now presents this as a workspace-command model:
|
||||
|
||||
- `Services` are long-running commands that stay supervised.
|
||||
- `Jobs` are one-shot commands that run once and exit.
|
||||
- Raw runtime JSON is still available for advanced config, but it is no longer the primary mental model.
|
||||
|
||||
## Project runtime configuration
|
||||
|
||||
You can define how to run a project on the project workspace itself.
|
||||
|
||||
- Project workspace runtime config describes how to run services for that project checkout.
|
||||
- Project workspace runtime config describes the services and jobs available for that project checkout.
|
||||
- This is the default runtime configuration that child execution workspaces may inherit.
|
||||
- Defining the config does not start anything by itself.
|
||||
|
||||
## Manual runtime control
|
||||
|
||||
Runtime services are manually controlled from the UI.
|
||||
Workspace commands are manually controlled from the UI.
|
||||
|
||||
- Project workspace runtime services are started and stopped from the project workspace UI.
|
||||
- Execution workspace runtime services are started and stopped from the execution workspace UI.
|
||||
- Paperclip does not automatically start or stop these runtime services as part of issue execution.
|
||||
- Paperclip also does not automatically restart workspace runtime services on server boot.
|
||||
- Project workspace services are started and stopped from the project workspace UI, and project jobs can be run on demand there.
|
||||
- Execution workspace services are started and stopped from the execution workspace UI, and execution-workspace jobs can be run on demand there.
|
||||
- Paperclip does not automatically start or stop these workspace services as part of issue execution.
|
||||
- Paperclip also does not automatically restart workspace services on server boot.
|
||||
|
||||
## Execution workspace inheritance
|
||||
|
||||
@@ -29,7 +35,7 @@ Execution workspaces isolate code and runtime state from the project primary wor
|
||||
- An isolated execution workspace has its own checkout path, branch, and local runtime instance.
|
||||
- The runtime configuration may inherit from the linked project workspace by default.
|
||||
- The execution workspace may override that runtime configuration with its own workspace-specific settings.
|
||||
- The inherited configuration answers "how to run the service", but the running process is still specific to that execution workspace.
|
||||
- The inherited configuration answers "which commands exist and how to run them", but any running service process is still specific to that execution workspace.
|
||||
|
||||
## Issues and execution workspaces
|
||||
|
||||
@@ -38,7 +44,7 @@ Issues are attached to execution workspace behavior, not to automatic runtime ma
|
||||
- An issue may create a new execution workspace when you choose an isolated workspace mode.
|
||||
- An issue may reuse an existing execution workspace when you choose reuse.
|
||||
- Multiple issues may intentionally share one execution workspace so they can work against the same branch and running runtime services.
|
||||
- Assigning or running an issue does not automatically start or stop runtime services for that workspace.
|
||||
- Assigning or running an issue does not automatically start or stop workspace services for that workspace.
|
||||
|
||||
## Execution workspace lifecycle
|
||||
|
||||
@@ -62,7 +68,7 @@ Heartbeat still resolves a workspace for the run, but that is about code locatio
|
||||
|
||||
With the current implementation:
|
||||
|
||||
- Project workspace runtime config is the fallback for execution workspace UI controls.
|
||||
- Project workspace command config is the fallback for execution workspace UI controls.
|
||||
- Execution workspace runtime overrides are stored on the execution workspace.
|
||||
- Heartbeat runs do not auto-start workspace runtime services.
|
||||
- Server startup does not auto-restart workspace runtime services.
|
||||
- Heartbeat runs do not auto-start workspace services.
|
||||
- Server startup does not auto-restart workspace services.
|
||||
+2
-1
@@ -13,7 +13,8 @@
|
||||
"dev:ui": "pnpm --filter @paperclipai/ui dev",
|
||||
"build": "pnpm run preflight:workspace-links && pnpm -r build",
|
||||
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
|
||||
"test": "pnpm run preflight:workspace-links && vitest",
|
||||
"test": "pnpm run test:run",
|
||||
"test:watch": "pnpm run preflight:workspace-links && vitest",
|
||||
"test:run": "pnpm run preflight:workspace-links && vitest run",
|
||||
"db:generate": "pnpm --filter @paperclipai/db generate",
|
||||
"db:migrate": "pnpm --filter @paperclipai/db migrate",
|
||||
|
||||
@@ -253,6 +253,7 @@ type PaperclipWakeComment = {
|
||||
type PaperclipWakePayload = {
|
||||
reason: string | null;
|
||||
issue: PaperclipWakeIssue | null;
|
||||
checkedOutByHarness: boolean;
|
||||
executionStage: PaperclipWakeExecutionStage | null;
|
||||
commentIds: string[];
|
||||
latestCommentId: string | null;
|
||||
@@ -363,6 +364,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
||||
return {
|
||||
reason: asString(payload.reason, "").trim() || null,
|
||||
issue: normalizePaperclipWakeIssue(payload.issue),
|
||||
checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false),
|
||||
executionStage,
|
||||
commentIds,
|
||||
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
||||
@@ -432,6 +434,9 @@ export function renderPaperclipWakePrompt(
|
||||
if (normalized.issue?.priority) {
|
||||
lines.push(`- issue priority: ${normalized.issue.priority}`);
|
||||
}
|
||||
if (normalized.checkedOutByHarness) {
|
||||
lines.push("- checkout: already claimed by the harness for this run");
|
||||
}
|
||||
if (normalized.missingCount > 0) {
|
||||
lines.push(`- omitted comments: ${normalized.missingCount}`);
|
||||
}
|
||||
@@ -465,6 +470,15 @@ export function renderPaperclipWakePrompt(
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.checkedOutByHarness) {
|
||||
lines.push(
|
||||
"",
|
||||
"The harness already checked out this issue for the current run.",
|
||||
"Do not call `/api/issues/{id}/checkout` again unless you intentionally switch to a different task.",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.comments.length > 0) {
|
||||
lines.push("New comments in order:");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "company_user_sidebar_preferences" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"project_order" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_sidebar_preferences" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"company_order" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_user_sidebar_preferences" ADD CONSTRAINT "company_user_sidebar_preferences_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "company_user_sidebar_preferences_company_idx" ON "company_user_sidebar_preferences" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "company_user_sidebar_preferences_user_idx" ON "company_user_sidebar_preferences" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "company_user_sidebar_preferences_company_user_uq" ON "company_user_sidebar_preferences" USING btree ("company_id","user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "user_sidebar_preferences_user_uq" ON "user_sidebar_preferences" USING btree ("user_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -393,6 +393,13 @@
|
||||
"when": 1775825256196,
|
||||
"tag": "0055_kind_weapon_omega",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 56,
|
||||
"version": "7",
|
||||
"when": 1776084034244,
|
||||
"tag": "0056_spooky_ultragirl",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const companyUserSidebarPreferences = pgTable(
|
||||
"company_user_sidebar_preferences",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull(),
|
||||
projectOrder: jsonb("project_order").$type<string[]>().notNull().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("company_user_sidebar_preferences_company_idx").on(table.companyId),
|
||||
userIdx: index("company_user_sidebar_preferences_user_idx").on(table.userId),
|
||||
companyUserUq: uniqueIndex("company_user_sidebar_preferences_company_user_uq").on(
|
||||
table.companyId,
|
||||
table.userId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -3,10 +3,12 @@ export { companyLogos } from "./company_logos.js";
|
||||
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
|
||||
export { instanceSettings } from "./instance_settings.js";
|
||||
export { instanceUserRoles } from "./instance_user_roles.js";
|
||||
export { userSidebarPreferences } from "./user_sidebar_preferences.js";
|
||||
export { agents } from "./agents.js";
|
||||
export { boardApiKeys } from "./board_api_keys.js";
|
||||
export { cliAuthChallenges } from "./cli_auth_challenges.js";
|
||||
export { companyMemberships } from "./company_memberships.js";
|
||||
export { companyUserSidebarPreferences } from "./company_user_sidebar_preferences.js";
|
||||
export { principalPermissionGrants } from "./principal_permission_grants.js";
|
||||
export { invites } from "./invites.js";
|
||||
export { joinRequests } from "./join_requests.js";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
|
||||
export const userSidebarPreferences = pgTable(
|
||||
"user_sidebar_preferences",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: text("user_id").notNull(),
|
||||
companyOrder: jsonb("company_order").$type<string[]>().notNull().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
userUq: uniqueIndex("user_sidebar_preferences_user_uq").on(table.userId),
|
||||
}),
|
||||
);
|
||||
@@ -13,6 +13,7 @@ export const API = {
|
||||
activity: `${API_PREFIX}/activity`,
|
||||
dashboard: `${API_PREFIX}/dashboard`,
|
||||
sidebarBadges: `${API_PREFIX}/sidebar-badges`,
|
||||
sidebarPreferences: `${API_PREFIX}/sidebar-preferences`,
|
||||
invites: `${API_PREFIX}/invites`,
|
||||
joinRequests: `${API_PREFIX}/join-requests`,
|
||||
members: `${API_PREFIX}/members`,
|
||||
|
||||
@@ -237,7 +237,11 @@ export type {
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
ExecutionWorkspaceCloseReadinessState,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
WorkspaceCommandDefinition,
|
||||
WorkspaceCommandKind,
|
||||
WorkspaceRuntimeControlTarget,
|
||||
WorkspaceRuntimeService,
|
||||
WorkspaceRuntimeServiceStateMap,
|
||||
WorkspaceOperation,
|
||||
WorkspaceOperationPhase,
|
||||
WorkspaceOperationStatus,
|
||||
@@ -306,6 +310,7 @@ export type {
|
||||
DashboardSummary,
|
||||
ActivityEvent,
|
||||
SidebarBadges,
|
||||
SidebarOrderPreference,
|
||||
InboxDismissal,
|
||||
AccessUserProfile,
|
||||
CompanyMemberRecord,
|
||||
@@ -389,6 +394,21 @@ export type {
|
||||
ProviderQuotaResult,
|
||||
} from "./types/index.js";
|
||||
|
||||
export {
|
||||
sidebarOrderPreferenceSchema,
|
||||
upsertSidebarOrderPreferenceSchema,
|
||||
type UpsertSidebarOrderPreference,
|
||||
} from "./validators/sidebar-preferences.js";
|
||||
|
||||
export { workspaceRuntimeControlTargetSchema } from "./validators/execution-workspace.js";
|
||||
export {
|
||||
findWorkspaceCommandDefinition,
|
||||
listWorkspaceCommandDefinitions,
|
||||
listWorkspaceServiceCommandDefinitions,
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
scoreWorkspaceRuntimeServiceMatch,
|
||||
} from "./workspace-commands.js";
|
||||
|
||||
export {
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
FEEDBACK_TARGET_TYPES,
|
||||
|
||||
@@ -50,11 +50,11 @@ export function trackGoalCreated(
|
||||
|
||||
export function trackAgentCreated(
|
||||
client: TelemetryClient,
|
||||
dims: { agentRole: string; agentId: string },
|
||||
dims: { agentRole: string; agentId?: string },
|
||||
): void {
|
||||
client.track("agent.created", {
|
||||
agent_role: dims.agentRole,
|
||||
agent_id: dims.agentId,
|
||||
...(dims.agentId ? { agent_id: dims.agentId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,21 +70,23 @@ export function trackSkillImported(
|
||||
|
||||
export function trackAgentFirstHeartbeat(
|
||||
client: TelemetryClient,
|
||||
dims: { agentRole: string; agentId: string },
|
||||
dims: { agentRole: string; agentId?: string },
|
||||
): void {
|
||||
client.track("agent.first_heartbeat", {
|
||||
agent_role: dims.agentRole,
|
||||
agent_id: dims.agentId,
|
||||
...(dims.agentId ? { agent_id: dims.agentId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function trackAgentTaskCompleted(
|
||||
client: TelemetryClient,
|
||||
dims: { agentRole: string; agentId: string },
|
||||
dims: { agentRole: string; agentId?: string; adapterType?: string; model?: string },
|
||||
): void {
|
||||
client.track("agent.task_completed", {
|
||||
agent_role: dims.agentRole,
|
||||
agent_id: dims.agentId,
|
||||
...(dims.agentId ? { agent_id: dims.agentId } : {}),
|
||||
...(dims.adapterType ? { adapter_type: dims.adapterType } : {}),
|
||||
...(dims.model ? { model: dims.model } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,11 @@ export type {
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
ExecutionWorkspaceCloseReadinessState,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
WorkspaceCommandDefinition,
|
||||
WorkspaceCommandKind,
|
||||
WorkspaceRuntimeControlTarget,
|
||||
WorkspaceRuntimeService,
|
||||
WorkspaceRuntimeServiceStateMap,
|
||||
WorkspaceRuntimeDesiredState,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
@@ -165,6 +169,7 @@ export type { LiveEvent } from "./live.js";
|
||||
export type { DashboardSummary } from "./dashboard.js";
|
||||
export type { ActivityEvent } from "./activity.js";
|
||||
export type { SidebarBadges } from "./sidebar-badges.js";
|
||||
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
|
||||
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||
export type {
|
||||
AccessUserProfile,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface SidebarOrderPreference {
|
||||
orderedIds: string[];
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
@@ -46,6 +46,27 @@ export type ExecutionWorkspaceCloseActionKind =
|
||||
| "remove_local_directory";
|
||||
|
||||
export type WorkspaceRuntimeDesiredState = "running" | "stopped";
|
||||
export type WorkspaceRuntimeServiceStateMap = Record<string, WorkspaceRuntimeDesiredState>;
|
||||
export type WorkspaceCommandKind = "service" | "job";
|
||||
|
||||
export interface WorkspaceCommandSource {
|
||||
type: "paperclip";
|
||||
key: "commands" | "services" | "jobs";
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: WorkspaceCommandKind;
|
||||
command: string | null;
|
||||
cwd: string | null;
|
||||
lifecycle: "shared" | "ephemeral" | null;
|
||||
serviceIndex: number | null;
|
||||
disabledReason: string | null;
|
||||
rawConfig: Record<string, unknown>;
|
||||
source: WorkspaceCommandSource;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceStrategy {
|
||||
type: ExecutionWorkspaceStrategyType;
|
||||
@@ -62,11 +83,19 @@ export interface ExecutionWorkspaceConfig {
|
||||
cleanupCommand: string | null;
|
||||
workspaceRuntime: Record<string, unknown> | null;
|
||||
desiredState: WorkspaceRuntimeDesiredState | null;
|
||||
serviceStates?: WorkspaceRuntimeServiceStateMap | null;
|
||||
}
|
||||
|
||||
export interface ProjectWorkspaceRuntimeConfig {
|
||||
workspaceRuntime: Record<string, unknown> | null;
|
||||
desiredState: WorkspaceRuntimeDesiredState | null;
|
||||
serviceStates?: WorkspaceRuntimeServiceStateMap | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceRuntimeControlTarget {
|
||||
workspaceCommandId?: string | null;
|
||||
runtimeServiceId?: string | null;
|
||||
serviceIndex?: number | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceCloseAction {
|
||||
@@ -187,6 +216,7 @@ export interface WorkspaceRuntimeService {
|
||||
stoppedAt: Date | null;
|
||||
stopPolicy: Record<string, unknown> | null;
|
||||
healthStatus: "unknown" | "healthy" | "unhealthy";
|
||||
configIndex?: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -14,6 +14,13 @@ export const executionWorkspaceConfigSchema = z.object({
|
||||
cleanupCommand: z.string().optional().nullable(),
|
||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||
desiredState: z.enum(["running", "stopped"]).optional().nullable(),
|
||||
serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceRuntimeControlTargetSchema = z.object({
|
||||
workspaceCommandId: z.string().min(1).optional().nullable(),
|
||||
runtimeServiceId: z.string().uuid().optional().nullable(),
|
||||
serviceIndex: z.number().int().nonnegative().optional().nullable(),
|
||||
}).strict();
|
||||
|
||||
export const executionWorkspaceCloseReadinessStateSchema = z.enum([
|
||||
@@ -62,6 +69,36 @@ export const executionWorkspaceCloseGitReadinessSchema = z.object({
|
||||
createdByRuntime: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceRuntimeServiceSchema = z.object({
|
||||
id: z.string(),
|
||||
companyId: z.string().uuid(),
|
||||
projectId: z.string().uuid().nullable(),
|
||||
projectWorkspaceId: z.string().uuid().nullable(),
|
||||
executionWorkspaceId: z.string().uuid().nullable(),
|
||||
issueId: z.string().uuid().nullable(),
|
||||
scopeType: z.enum(["project_workspace", "execution_workspace", "run", "agent"]),
|
||||
scopeId: z.string().nullable(),
|
||||
serviceName: z.string(),
|
||||
status: z.enum(["starting", "running", "stopped", "failed"]),
|
||||
lifecycle: z.enum(["shared", "ephemeral"]),
|
||||
reuseKey: z.string().nullable(),
|
||||
command: z.string().nullable(),
|
||||
cwd: z.string().nullable(),
|
||||
port: z.number().int().nullable(),
|
||||
url: z.string().nullable(),
|
||||
provider: z.enum(["local_process", "adapter_managed"]),
|
||||
providerRef: z.string().nullable(),
|
||||
ownerAgentId: z.string().uuid().nullable(),
|
||||
startedByRunId: z.string().uuid().nullable(),
|
||||
lastUsedAt: z.coerce.date(),
|
||||
startedAt: z.coerce.date(),
|
||||
stoppedAt: z.coerce.date().nullable(),
|
||||
stopPolicy: z.record(z.unknown()).nullable(),
|
||||
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]),
|
||||
configIndex: z.number().int().nonnegative().nullable().optional(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
}).strict();
|
||||
export const executionWorkspaceCloseReadinessSchema = z.object({
|
||||
workspaceId: z.string().uuid(),
|
||||
state: executionWorkspaceCloseReadinessStateSchema,
|
||||
@@ -73,35 +110,7 @@ export const executionWorkspaceCloseReadinessSchema = z.object({
|
||||
isSharedWorkspace: z.boolean(),
|
||||
isProjectPrimaryWorkspace: z.boolean(),
|
||||
git: executionWorkspaceCloseGitReadinessSchema.nullable(),
|
||||
runtimeServices: z.array(z.object({
|
||||
id: z.string(),
|
||||
companyId: z.string().uuid(),
|
||||
projectId: z.string().uuid().nullable(),
|
||||
projectWorkspaceId: z.string().uuid().nullable(),
|
||||
executionWorkspaceId: z.string().uuid().nullable(),
|
||||
issueId: z.string().uuid().nullable(),
|
||||
scopeType: z.enum(["project_workspace", "execution_workspace", "run", "agent"]),
|
||||
scopeId: z.string().nullable(),
|
||||
serviceName: z.string(),
|
||||
status: z.enum(["starting", "running", "stopped", "failed"]),
|
||||
lifecycle: z.enum(["shared", "ephemeral"]),
|
||||
reuseKey: z.string().nullable(),
|
||||
command: z.string().nullable(),
|
||||
cwd: z.string().nullable(),
|
||||
port: z.number().int().nullable(),
|
||||
url: z.string().nullable(),
|
||||
provider: z.enum(["local_process", "adapter_managed"]),
|
||||
providerRef: z.string().nullable(),
|
||||
ownerAgentId: z.string().uuid().nullable(),
|
||||
startedByRunId: z.string().uuid().nullable(),
|
||||
lastUsedAt: z.coerce.date(),
|
||||
startedAt: z.coerce.date(),
|
||||
stoppedAt: z.coerce.date().nullable(),
|
||||
stopPolicy: z.record(z.unknown()).nullable(),
|
||||
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
}).strict()),
|
||||
runtimeServices: z.array(workspaceRuntimeServiceSchema),
|
||||
}).strict();
|
||||
|
||||
export const updateExecutionWorkspaceSchema = z.object({
|
||||
|
||||
@@ -32,6 +32,11 @@ export {
|
||||
upsertIssueFeedbackVoteSchema,
|
||||
type UpsertIssueFeedbackVote,
|
||||
} from "./feedback.js";
|
||||
export {
|
||||
sidebarOrderPreferenceSchema,
|
||||
upsertSidebarOrderPreferenceSchema,
|
||||
type UpsertSidebarOrderPreference,
|
||||
} from "./sidebar-preferences.js";
|
||||
export {
|
||||
companySkillSourceTypeSchema,
|
||||
companySkillTrustLevelSchema,
|
||||
|
||||
@@ -146,6 +146,7 @@ export const createIssueLabelSchema = z.object({
|
||||
export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
|
||||
|
||||
export const updateIssueSchema = createIssueSchema.partial().extend({
|
||||
assigneeAgentId: z.string().trim().min(1).optional().nullable(),
|
||||
comment: z.string().min(1).optional(),
|
||||
reopen: z.boolean().optional(),
|
||||
interrupt: z.boolean().optional(),
|
||||
|
||||
@@ -31,6 +31,7 @@ export const projectExecutionWorkspacePolicySchema = z
|
||||
export const projectWorkspaceRuntimeConfigSchema = z.object({
|
||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||
desiredState: z.enum(["running", "stopped"]).optional().nullable(),
|
||||
serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(),
|
||||
}).strict();
|
||||
|
||||
const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const sidebarOrderedIdSchema = z.string().uuid();
|
||||
|
||||
export const sidebarOrderPreferenceSchema = z.object({
|
||||
orderedIds: z.array(sidebarOrderedIdSchema),
|
||||
updatedAt: z.coerce.date().nullable(),
|
||||
});
|
||||
|
||||
export const upsertSidebarOrderPreferenceSchema = z.object({
|
||||
orderedIds: z.array(sidebarOrderedIdSchema),
|
||||
});
|
||||
|
||||
export type UpsertSidebarOrderPreference = z.infer<typeof upsertSidebarOrderPreferenceSchema>;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findWorkspaceCommandDefinition,
|
||||
listWorkspaceCommandDefinitions,
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
} from "./workspace-commands.js";
|
||||
|
||||
describe("workspace command helpers", () => {
|
||||
it("derives service and job commands from command-first runtime config", () => {
|
||||
const commands = listWorkspaceCommandDefinitions({
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(commands).toEqual([
|
||||
expect.objectContaining({ id: "web", kind: "service", serviceIndex: 0 }),
|
||||
expect.objectContaining({ id: "db-migrate", kind: "job", serviceIndex: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to legacy services and jobs arrays", () => {
|
||||
const commands = listWorkspaceCommandDefinitions({
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
jobs: [{ name: "lint", command: "pnpm lint" }],
|
||||
});
|
||||
|
||||
expect(commands).toEqual([
|
||||
expect.objectContaining({ id: "service:web", kind: "service", serviceIndex: 0 }),
|
||||
expect.objectContaining({ id: "job:lint", kind: "job", serviceIndex: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches a configured service command to the current runtime service", () => {
|
||||
const workspaceRuntime = {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev", cwd: "." },
|
||||
],
|
||||
};
|
||||
const command = findWorkspaceCommandDefinition(workspaceRuntime, "web");
|
||||
expect(command).not.toBeNull();
|
||||
|
||||
const match = matchWorkspaceRuntimeServiceToCommand(command!, [
|
||||
{
|
||||
id: "runtime-web",
|
||||
serviceName: "web",
|
||||
command: "pnpm dev",
|
||||
cwd: "/repo",
|
||||
configIndex: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(match).toEqual(expect.objectContaining({ id: "runtime-web" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { WorkspaceCommandDefinition, WorkspaceRuntimeService } from "./types/workspace-runtime.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function slugify(value: string | null | undefined) {
|
||||
const normalized = (value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function deriveWorkspaceCommandId(input: {
|
||||
kind: WorkspaceCommandDefinition["kind"];
|
||||
explicitId: string | null;
|
||||
name: string;
|
||||
index: number;
|
||||
}) {
|
||||
const explicitId = slugify(input.explicitId);
|
||||
if (explicitId) return explicitId;
|
||||
const nameSlug = slugify(input.name);
|
||||
return nameSlug ? `${input.kind}:${nameSlug}` : `${input.kind}:${input.index + 1}`;
|
||||
}
|
||||
|
||||
function buildWorkspaceCommandDefinition(input: {
|
||||
entry: Record<string, unknown>;
|
||||
kind: WorkspaceCommandDefinition["kind"];
|
||||
sourceKey: WorkspaceCommandDefinition["source"]["key"];
|
||||
sourceIndex: number;
|
||||
serviceIndex: number | null;
|
||||
fallbackName: string;
|
||||
}): WorkspaceCommandDefinition {
|
||||
return {
|
||||
id: deriveWorkspaceCommandId({
|
||||
kind: input.kind,
|
||||
explicitId: readNonEmptyString(input.entry.id),
|
||||
name:
|
||||
readNonEmptyString(input.entry.name)
|
||||
?? readNonEmptyString(input.entry.label)
|
||||
?? readNonEmptyString(input.entry.title)
|
||||
?? input.fallbackName,
|
||||
index: input.sourceIndex,
|
||||
}),
|
||||
name:
|
||||
readNonEmptyString(input.entry.name)
|
||||
?? readNonEmptyString(input.entry.label)
|
||||
?? readNonEmptyString(input.entry.title)
|
||||
?? input.fallbackName,
|
||||
kind: input.kind,
|
||||
command: readNonEmptyString(input.entry.command),
|
||||
cwd: readNonEmptyString(input.entry.cwd),
|
||||
lifecycle:
|
||||
input.kind === "service"
|
||||
? input.entry.lifecycle === "ephemeral"
|
||||
? "ephemeral"
|
||||
: "shared"
|
||||
: null,
|
||||
serviceIndex: input.serviceIndex,
|
||||
disabledReason: readNonEmptyString(input.entry.disabledReason),
|
||||
rawConfig: { ...input.entry },
|
||||
source: {
|
||||
type: "paperclip",
|
||||
key: input.sourceKey,
|
||||
index: input.sourceIndex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueWorkspaceCommandId(
|
||||
seen: Set<string>,
|
||||
commandId: string,
|
||||
sourceKey: WorkspaceCommandDefinition["source"]["key"],
|
||||
sourceIndex: number,
|
||||
) {
|
||||
if (!seen.has(commandId)) {
|
||||
seen.add(commandId);
|
||||
return commandId;
|
||||
}
|
||||
const fallbackId = `${commandId}-${sourceKey}-${sourceIndex + 1}`;
|
||||
seen.add(fallbackId);
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
function readCommandEntries(
|
||||
workspaceRuntime: Record<string, unknown> | null | undefined,
|
||||
key: "commands" | "services" | "jobs",
|
||||
) {
|
||||
const raw = workspaceRuntime?.[key];
|
||||
return Array.isArray(raw) ? raw.filter((entry): entry is Record<string, unknown> => isRecord(entry)) : [];
|
||||
}
|
||||
|
||||
export function listWorkspaceCommandDefinitions(
|
||||
workspaceRuntime: Record<string, unknown> | null | undefined,
|
||||
): WorkspaceCommandDefinition[] {
|
||||
if (!workspaceRuntime) return [];
|
||||
|
||||
const commandEntries = readCommandEntries(workspaceRuntime, "commands");
|
||||
const seenIds = new Set<string>();
|
||||
let nextServiceIndex = 0;
|
||||
|
||||
const finalize = (command: WorkspaceCommandDefinition) => ({
|
||||
...command,
|
||||
id: uniqueWorkspaceCommandId(seenIds, command.id, command.source.key, command.source.index),
|
||||
});
|
||||
|
||||
if (commandEntries.length > 0) {
|
||||
return commandEntries.map((entry, index) =>
|
||||
finalize(buildWorkspaceCommandDefinition({
|
||||
entry,
|
||||
kind: entry.kind === "job" ? "job" : "service",
|
||||
sourceKey: "commands",
|
||||
sourceIndex: index,
|
||||
serviceIndex: entry.kind === "job" ? null : nextServiceIndex++,
|
||||
fallbackName: entry.kind === "job" ? `Job ${index + 1}` : `Service ${index + 1}`,
|
||||
})));
|
||||
}
|
||||
|
||||
const serviceDefinitions = readCommandEntries(workspaceRuntime, "services").map((entry, index) =>
|
||||
finalize(buildWorkspaceCommandDefinition({
|
||||
entry,
|
||||
kind: "service",
|
||||
sourceKey: "services",
|
||||
sourceIndex: index,
|
||||
serviceIndex: nextServiceIndex++,
|
||||
fallbackName: `Service ${index + 1}`,
|
||||
})));
|
||||
const jobDefinitions = readCommandEntries(workspaceRuntime, "jobs").map((entry, index) =>
|
||||
finalize(buildWorkspaceCommandDefinition({
|
||||
entry,
|
||||
kind: "job",
|
||||
sourceKey: "jobs",
|
||||
sourceIndex: index,
|
||||
serviceIndex: null,
|
||||
fallbackName: `Job ${index + 1}`,
|
||||
})));
|
||||
|
||||
return [...serviceDefinitions, ...jobDefinitions];
|
||||
}
|
||||
|
||||
export function listWorkspaceServiceCommandDefinitions(
|
||||
workspaceRuntime: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
return listWorkspaceCommandDefinitions(workspaceRuntime).filter((command) => command.kind === "service");
|
||||
}
|
||||
|
||||
export function findWorkspaceCommandDefinition(
|
||||
workspaceRuntime: Record<string, unknown> | null | undefined,
|
||||
workspaceCommandId: string | null | undefined,
|
||||
) {
|
||||
const normalizedId = readNonEmptyString(workspaceCommandId);
|
||||
if (!normalizedId) return null;
|
||||
return listWorkspaceCommandDefinitions(workspaceRuntime).find((command) => command.id === normalizedId) ?? null;
|
||||
}
|
||||
|
||||
export function scoreWorkspaceRuntimeServiceMatch(
|
||||
command: Pick<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd">,
|
||||
runtimeService: Pick<WorkspaceRuntimeService, "configIndex" | "serviceName" | "command" | "cwd">,
|
||||
) {
|
||||
if (command.serviceIndex !== null && runtimeService.configIndex !== null && runtimeService.configIndex !== undefined) {
|
||||
return runtimeService.configIndex === command.serviceIndex ? 100 : -1;
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
if (runtimeService.serviceName === command.name) score += 4;
|
||||
if ((runtimeService.command ?? null) === (command.command ?? null)) score += 4;
|
||||
if (
|
||||
command.cwd
|
||||
&& runtimeService.cwd
|
||||
&& (runtimeService.cwd === command.cwd || runtimeService.cwd.endsWith(`/${command.cwd}`))
|
||||
) {
|
||||
score += 2;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
export function matchWorkspaceRuntimeServiceToCommand<
|
||||
T extends Pick<WorkspaceRuntimeService, "configIndex" | "serviceName" | "command" | "cwd">,
|
||||
>(
|
||||
command: Pick<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd">,
|
||||
runtimeServices: T[] | null | undefined,
|
||||
) {
|
||||
let bestMatch: T | null = null;
|
||||
let bestScore = -1;
|
||||
|
||||
for (const runtimeService of runtimeServices ?? []) {
|
||||
const score = scoreWorkspaceRuntimeServiceMatch(command, runtimeService);
|
||||
if (score > bestScore) {
|
||||
bestMatch = runtimeService;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore > 0 ? bestMatch : null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# v2026.410.0
|
||||
|
||||
> Released: 2026-04-13
|
||||
|
||||
## Security
|
||||
|
||||
- **Authorization hardening (GHSA-68qg-g8mg-6pr7)** — Scoped import, approval, activity, and heartbeat API routes to enforce proper authorization checks. Previously, certain administrative endpoints were accessible without adequate permission verification. All users are strongly encouraged to upgrade. ([#3315](https://github.com/cryppadotta/paperclip/pull/3315))
|
||||
- **Removed hardcoded JWT secret fallback** — The `createBetterAuthInstance` function no longer falls back to a hardcoded JWT secret, closing a credential-hygiene gap.
|
||||
- **Redact Bearer tokens in logs** — Server log output now redacts Bearer tokens to prevent accidental credential exposure. ([#2659](https://github.com/cryppadotta/paperclip/pull/2659))
|
||||
- **Dependency bumps** — Updated `multer` to 2.1.1 (HIGH CVEs) and `rollup` to 4.59.0 (path-traversal CVE). ([#2819](https://github.com/cryppadotta/paperclip/pull/2819))
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Issue-to-issue navigation** — Faster navigation between issues with scroll reset, prefetch, and detail-view optimizations. ([#3542](https://github.com/cryppadotta/paperclip/pull/3542))
|
||||
- **Auto-checkout for scoped wakes** — Agent harness now automatically checks out the scoped issue on comment-driven wakes, reducing latency for agent heartbeats. ([#3538](https://github.com/cryppadotta/paperclip/pull/3538))
|
||||
- **Inbox parent-child nesting** — Issues in the Mine inbox can now be grouped by parent, with a toggle and keyboard-traversable nested rows.
|
||||
- **Keyboard shortcut cheatsheet** — Press `?` to see all available keyboard shortcuts in a dialog.
|
||||
- **Issue search in inbox** — Broadened comment matching for inbox issue search with fallback.
|
||||
- **Codex fast mode** — Added fast mode support for `codex_local` adapters with env probe safeguards.
|
||||
- **Backups with retention** — Gzip-compressed database backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings.
|
||||
- **AWS Bedrock auth** — Added AWS Bedrock authentication support on `claude-local` adapters. ([#2793](https://github.com/cryppadotta/paperclip/pull/2793))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Issue detail stability** — Faster comment loading, reduced rerenders on interrupted runs, stable transcript rendering for non-succeeded runs.
|
||||
- **Execution workspaces** — Fixed linked worktree reuse, dev runner isolation, workspace import regressions, and workspace preflight through server toolchain.
|
||||
- **Agent runtime** — Hardened heartbeat and adapter runtime workflows, scoped-wake fast path skips full heartbeat on comment wakes, signoff stage access fixes.
|
||||
- **Execution policy** — Fixed non-participant stage mutation rejection, decision persistence, and signoff PR follow-up flows.
|
||||
- **Chat UX polish** — Shimmer animation improvements, image gallery in chat messages, inline comment composer, Working/Worked status tokens.
|
||||
- **Inbox refinements** — Avoid refetching on filter-only changes, archive shortcut fix, badge fixture alignment, nesting column alignment.
|
||||
- **Typing performance** — Fixed typing lag in long comment threads. ([#3163](https://github.com/cryppadotta/paperclip/pull/3163))
|
||||
- **Issue list grouping** — Added workspace and parent issue grouping to the issues list view.
|
||||
- **Worktree tooling** — Improved worktree helpers, bind presets for deployment setup, tailnet bind hardening.
|
||||
- **Plugin SDK** — Plugin SDK now prepares before CLI dev boot. ([#3343](https://github.com/cryppadotta/paperclip/pull/3343))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Agent env bindings** — Cleared agent env bindings now persist correctly on save.
|
||||
- **Comment editor sync** — Hardened issue comment editor synchronization.
|
||||
- **Document revisions** — Latest issue document revision stays current in the UI. ([#3342](https://github.com/cryppadotta/paperclip/pull/3342))
|
||||
- **Claude instructions** — Fixed instruction sibling path hints, gate file I/O to fresh sessions only, skip `--append-system-prompt-file` on resumed sessions.
|
||||
- **Codex transcript** — Fixed Codex tool-use transcript completion parsing.
|
||||
- **Backup cleanup** — Orphaned `.sql` files cleaned up on compression failure; stale startup log fixed.
|
||||
- **Chat layout** — Fixed avatar positioning, activity line alignment, comment alignment, and feedback panel closing.
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
Multiple database migrations will run automatically on startup. All migrations are additive — no existing data is modified.
|
||||
|
||||
**Security:** This release addresses [GHSA-68qg-g8mg-6pr7](https://github.com/cryppadotta/paperclip/security/advisories/GHSA-68qg-g8mg-6pr7). All deployments should upgrade as soon as possible.
|
||||
@@ -0,0 +1,98 @@
|
||||
# v2026.413.0
|
||||
|
||||
> Released: 2026-04-13
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Issue chat thread** — Replaced the classic comment timeline with a full chat-style thread powered by assistant-ui. Agent run transcripts, chain-of-thought, and user messages now render inline as a continuous conversation with polished avatars, action bars, and relative timestamps. ([#3079](https://github.com/paperclipai/paperclip/pull/3079))
|
||||
- **External adapter plugin system** — Third-party adapters can now be installed as npm packages or loaded from local directories. Plugins declare a config schema and an optional UI transcript parser; built-in adapters can be overridden by external ones. Includes Hermes local session management and provider/model display in run details. ([#2649](https://github.com/paperclipai/paperclip/pull/2649), [#2650](https://github.com/paperclipai/paperclip/pull/2650), [#2651](https://github.com/paperclipai/paperclip/pull/2651), [#2654](https://github.com/paperclipai/paperclip/pull/2654), [#2655](https://github.com/paperclipai/paperclip/pull/2655), [#2659](https://github.com/paperclipai/paperclip/pull/2659), @plind-dm)
|
||||
- **Execution policies** — Issues can now carry a review/approval execution policy with multi-stage signoff workflows. Reviewers and approvers are selected per-stage, and Paperclip routes the issue through each stage automatically. ([#3222](https://github.com/paperclipai/paperclip/pull/3222))
|
||||
- **Blocker dependencies** — First-class issue blocker relations with automatic wake-on-dependency-resolved. Set `blockedByIssueIds` on any issue and Paperclip wakes the assignee when all blockers reach `done`. ([#2797](https://github.com/paperclipai/paperclip/pull/2797))
|
||||
- **Standalone MCP server** — New `@paperclipai/mcp-server` package exposing the Paperclip API as an MCP tool server, including approval creation. ([#2435](https://github.com/paperclipai/paperclip/pull/2435))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Board approvals** — Generic issue-linked board approvals with card styling and visibility improvements in the issue detail sidebar. ([#3220](https://github.com/paperclipai/paperclip/pull/3220))
|
||||
- **Inbox parent-child nesting** — Parent issues group their children in the inbox Mine view with a toggle button, j/k keyboard traversal across nested items, and collapsible groups. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz)
|
||||
- **Inbox workspace grouping** — Issues can now be grouped by workspace in the inbox with collapsible mobile groups and shared column controls across inbox and issues lists. ([#3356](https://github.com/paperclipai/paperclip/pull/3356))
|
||||
- **Issue search** — Trigram-indexed full-text search across titles, identifiers, descriptions, and comments with debounced input. Comment matches now surface in search results. ([#2999](https://github.com/paperclipai/paperclip/pull/2999))
|
||||
- **Sub-issues inline** — Sub-issues moved from a separate tab to inline display on the issue detail, with parent-inherited workspace defaults and assignee propagation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355))
|
||||
- **Document revision diff viewer** — Side-by-side diff viewer for issue document revisions with improved modal layout. ([#2792](https://github.com/paperclipai/paperclip/pull/2792))
|
||||
- **Keyboard shortcuts cheatsheet** — Press `?` to open a keyboard shortcut reference dialog; new `g i` (go to inbox), `g c` (comment composer), and inbox archive undo shortcuts. ([#2772](https://github.com/paperclipai/paperclip/pull/2772))
|
||||
- **Bedrock model selection** — Claude local adapter now supports AWS Bedrock authentication and model selection. ([#3033](https://github.com/paperclipai/paperclip/pull/3033), @kimnamu)
|
||||
- **Codex fast mode** — Added fast mode support for the Codex local adapter. ([#3383](https://github.com/paperclipai/paperclip/pull/3383))
|
||||
- **Backup improvements** — Gzip-compressed backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. ([#3015](https://github.com/paperclipai/paperclip/pull/3015), @aronprins)
|
||||
- **GitHub webhook signing modes** — Added `github_hmac` and `none` webhook signing modes with timing-safe HMAC comparison. ([#1961](https://github.com/paperclipai/paperclip/pull/1961), @antonio-mello-ai)
|
||||
- **Project environment variables** — Projects can now define environment variables that are inherited by workspace runs.
|
||||
- **Routine improvements** — Draft routine defaults, run-time overrides, routine title variables, and relaxed project/agent requirements for routines. ([#3220](https://github.com/paperclipai/paperclip/pull/3220))
|
||||
- **Workspace runtime controls** — Start/stop controls, runtime state reconciliation, runtime service JSON textarea improvements, and workspace branch/folder display in the issue properties sidebar. ([#3354](https://github.com/paperclipai/paperclip/pull/3354))
|
||||
- **Attachment improvements** — Arbitrary file attachments (not just images), drag-and-drop non-image files onto markdown editor, and square-cropped image gallery grid. ([#2749](https://github.com/paperclipai/paperclip/pull/2749))
|
||||
- **Image gallery in chat** — Clicking images in chat messages now opens a full gallery viewer.
|
||||
- **Mobile UX** — Gmail-inspired mobile top bar for inbox issue views, responsive execution workspace pages, mobile mention menu placement, and mobile comment copy button feedback.
|
||||
- **Sidebar order persistence** — Sidebar project and company ordering preferences now persist per-user.
|
||||
- **Skill auto-enable** — Mentioned skills are automatically enabled for heartbeat runs.
|
||||
- **Comment wake batching** — Multiple comment wakes are batched into a single inline payload for more efficient agent heartbeats.
|
||||
- **Server-side adapter pause/resume** — Builtin adapter types can now be paused/resumed from the server with `overridePaused`. ([#2542](https://github.com/paperclipai/paperclip/pull/2542), @plind-dm)
|
||||
- **Skill slash-command autocomplete** — Skill names now autocomplete in the editor.
|
||||
- **Worktree reseed command** — New CLI command to reseed worktrees from latest repo state. ([#3353](https://github.com/paperclipai/paperclip/pull/3353))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Issue detail stability** — Fixed visible refreshes during agent updates, comment post resets, ref update loops, split regressions, and main-pane focus on navigation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355))
|
||||
- **Inbox badge count** — Badge now correctly counts only unread Mine issues. ([#2512](https://github.com/paperclipai/paperclip/pull/2512), @AllenHyang)
|
||||
- **Inbox keyboard navigation** — Fixed j/k traversal across groups and nesting column alignment. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz)
|
||||
- **Vite HTML transforms** — Fixed repeated vite HTML transforms in dev mode.
|
||||
- **Auth session lookup** — Skipped unnecessary auth session lookups on non-API requests.
|
||||
- **Stale execution locks** — Fixed stale execution lock lifecycle with proper `executionAgentNameKey` clearing. ([#2643](https://github.com/paperclipai/paperclip/pull/2643), @chrisschwer)
|
||||
- **Agent env bindings** — Fixed cleared agent env bindings not persisting on save. ([#3232](https://github.com/paperclipai/paperclip/pull/3232), @officialasishkumar)
|
||||
- **Capabilities field** — Fixed blank screen when clearing the Capabilities field. ([#2442](https://github.com/paperclipai/paperclip/pull/2442), @sparkeros)
|
||||
- **Skill deletion** — Company skills can now be deleted with an agent usage check. ([#2441](https://github.com/paperclipai/paperclip/pull/2441), @DanielSousa)
|
||||
- **Claude session resume** — Fixed `--append-system-prompt-file` being sent on resumed Claude sessions and preserved instructions on resume fallback. ([#2949](https://github.com/paperclipai/paperclip/pull/2949), [#2936](https://github.com/paperclipai/paperclip/pull/2936), [#2937](https://github.com/paperclipai/paperclip/pull/2937), @Lempkey)
|
||||
- **JWT secret fallback** — Removed hardcoded JWT secret fallback; auth now properly falls back to `BETTER_AUTH_SECRET`. ([#3124](https://github.com/paperclipai/paperclip/pull/3124), @cleanunicorn)
|
||||
- **Agent auth JWT** — Fixed agent auth to fall back to `BETTER_AUTH_SECRET` when `PAPERCLIP_AGENT_JWT_SECRET` is absent. ([#2866](https://github.com/paperclipai/paperclip/pull/2866), @ergonaworks)
|
||||
- **Typing lag** — Fixed typing lag in long comment threads. ([#3163](https://github.com/paperclipai/paperclip/pull/3163))
|
||||
- **Infinite render loop** — Fixed infinite render loop in inbox mobile toolbar.
|
||||
- **Shimmer animation** — Fixed shimmer text using invalid `hsl()` wrapper on `oklch` colors, loop jitter, and added pause between repeats.
|
||||
- **Mention selection** — Restored touch mention selection and fixed spaced mention queries.
|
||||
- **Inbox archive** — Fixed archive flashing back after fade-out.
|
||||
- **Goal description** — Made goal description area scrollable in create dialog. ([#2148](https://github.com/paperclipai/paperclip/pull/2148), @shoaib050326)
|
||||
- **Worktree provisioning** — Fixed symlink relinking, fallback seeding, dependency hydration, and validated linked worktrees before reuse. ([#3354](https://github.com/paperclipai/paperclip/pull/3354))
|
||||
- **Node keepAliveTimeout** — Increased timeout behind reverse proxies to prevent 502 errors.
|
||||
- **Noisy request logging** — Reduced noisy server request logging.
|
||||
- **Codex tool-use transcripts** — Fixed Codex tool-use transcript completion parsing.
|
||||
- **Codex resume error** — Recognize missing-rollout Codex resume error as stale session.
|
||||
- **Pi quota exhaustion** — Treat Pi quota exhaustion as a failed run. ([#2305](https://github.com/paperclipai/paperclip/pull/2305))
|
||||
- **Security** — Bumped rollup to 4.59.0 (path-traversal CVE), multer to 2.1.1 (HIGH CVEs), and redacted Bearer tokens from server log output. ([#2909](https://github.com/paperclipai/paperclip/pull/2909), @marysomething99-prog)
|
||||
- **Issue identifier collisions** — Prevented identifier collisions during concurrent issue creation.
|
||||
- **OpenClaw CEO paths** — Fixed `$AGENT_HOME` references in CEO onboarding instructions to use relative paths. ([#3299](https://github.com/paperclipai/paperclip/pull/3299), @aronprins)
|
||||
- **Route authorization** — Scoped import, approvals, activity, and heartbeat routes properly. ([#3009](https://github.com/paperclipai/paperclip/pull/3009), @KhairulA)
|
||||
- **Windows adapter** — Uses `cmd.exe` for `.cmd`/`.bat` wrappers on Windows. ([#2662](https://github.com/paperclipai/paperclip/pull/2662), @wbelt)
|
||||
- **Markdown autoformat** — Fixed autoformat of pasted markdown in inline editor. ([#2733](https://github.com/paperclipai/paperclip/pull/2733), @davison)
|
||||
- **Paused agent dimming** — Correctly dim paused agents in list and org chart views; skip dimming on Paused filter tab. ([#2397](https://github.com/paperclipai/paperclip/pull/2397), @HearthCore)
|
||||
- **Import role fallback** — Import now reads agent role from frontmatter before defaulting to "agent". ([#2594](https://github.com/paperclipai/paperclip/pull/2594), @plind-dm)
|
||||
- **Backup cleanup** — Clean up orphaned `.sql` files on compression failure and fix stale startup log.
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
Eight new database migrations (`0049`–`0056`) will run automatically on startup. These add:
|
||||
|
||||
- Issue blocker relations table (`0049`)
|
||||
- Project environment variables (`0050`)
|
||||
- Trigram search indexes on issues and comments (`0051` — requires `pg_trgm` extension)
|
||||
- Execution policy decision tracking (`0052`)
|
||||
- Non-issue inbox dismissals (`0053`)
|
||||
- Relaxed routine constraints (`0054`)
|
||||
- Heartbeat run process group tracking (`0055`)
|
||||
- User sidebar preferences (`0056`)
|
||||
|
||||
All migrations are additive — no existing data is modified or removed.
|
||||
|
||||
**`pg_trgm` extension**: Migration `0051` creates the `pg_trgm` PostgreSQL extension for full-text search. If your database user does not have `CREATE EXTENSION` privileges, ask your DBA to run `CREATE EXTENSION IF NOT EXISTS pg_trgm;` before upgrading.
|
||||
|
||||
If you use external adapter plugins, note that built-in adapters can now be overridden by external ones. The `overriddenBuiltin` flag in the adapter API indicates when this is happening.
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @cryppadotta, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt
|
||||
@@ -0,0 +1,107 @@
|
||||
# v2026.414.0
|
||||
|
||||
> Released: 2026-04-14
|
||||
|
||||
## Security
|
||||
|
||||
- **Authorization hardening (GHSA-68qg-g8mg-6pr7)** — Scoped import, approval, activity, and heartbeat API routes to enforce proper authorization checks. Previously, certain administrative endpoints were accessible without adequate permission verification. All users are strongly encouraged to upgrade. ([#3315](https://github.com/paperclipai/paperclip/pull/3315), [#3009](https://github.com/paperclipai/paperclip/pull/3009), @KhairulA)
|
||||
- **Removed hardcoded JWT secret fallback** — The `createBetterAuthInstance` function no longer falls back to a hardcoded JWT secret, closing a credential-hygiene gap. ([#3124](https://github.com/paperclipai/paperclip/pull/3124), @cleanunicorn)
|
||||
- **Redact Bearer tokens in logs** — Server log output now redacts Bearer tokens to prevent accidental credential exposure.
|
||||
- **Dependency bumps** — Updated `multer` to 2.1.1 (HIGH CVEs) and `rollup` to 4.59.0 (path-traversal CVE). ([#2909](https://github.com/paperclipai/paperclip/pull/2909), @marysomething99-prog)
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Multi-user access and invites** — Full multi-user authentication, company roles, and invite management. Board users can create invite links, approve join requests, and manage member roles. Invite flows support auto-accept for signed-in users, paginated history, and human-readable requester identities in approval views.
|
||||
- **Human user identities everywhere** — Human users now appear with real names and avatars across activity feeds, issue tables, assignee pickers, and @-mention menus. A lightweight user directory endpoint powers consistent identity resolution across the UI.
|
||||
- **Issue chat thread** — Replaced the classic comment timeline with a full chat-style thread powered by assistant-ui. Agent run transcripts, chain-of-thought, and user messages render inline as a continuous conversation with polished avatars, action bars, and relative timestamps. ([#3079](https://github.com/paperclipai/paperclip/pull/3079))
|
||||
- **External adapter plugin system** — Third-party adapters can now be installed as npm packages or loaded from local directories. Plugins declare a config schema and an optional UI transcript parser; built-in adapters can be overridden by external ones. Includes Hermes local session management and provider/model display in run details. ([#2649](https://github.com/paperclipai/paperclip/pull/2649), [#2650](https://github.com/paperclipai/paperclip/pull/2650), [#2651](https://github.com/paperclipai/paperclip/pull/2651), [#2654](https://github.com/paperclipai/paperclip/pull/2654), [#2655](https://github.com/paperclipai/paperclip/pull/2655), [#2659](https://github.com/paperclipai/paperclip/pull/2659), @plind-dm)
|
||||
- **Execution policies** — Issues can carry a review/approval execution policy with multi-stage signoff workflows. Reviewers and approvers are selected per-stage, and Paperclip routes the issue through each stage automatically. ([#3222](https://github.com/paperclipai/paperclip/pull/3222))
|
||||
- **Blocker dependencies** — First-class issue blocker relations with automatic wake-on-dependency-resolved. Set `blockedByIssueIds` on any issue and Paperclip wakes the assignee when all blockers reach `done`. ([#2797](https://github.com/paperclipai/paperclip/pull/2797))
|
||||
- **Standalone MCP server** — New `@paperclipai/mcp-server` package exposing the Paperclip API as an MCP tool server, including approval creation. ([#2435](https://github.com/paperclipai/paperclip/pull/2435))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Invite UX polish** — Auto-submit for signed-in invites, inline auth flow, paginated invite history, requester identity in join approvals, and prevention of duplicate join requests and member re-invites.
|
||||
- **Board approvals** — Generic issue-linked board approvals with card styling and visibility improvements in the issue detail sidebar. ([#3220](https://github.com/paperclipai/paperclip/pull/3220))
|
||||
- **Inbox parent-child nesting** — Parent issues group their children in the inbox Mine view with a toggle button, j/k keyboard traversal across nested items, and collapsible groups. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz)
|
||||
- **Inbox workspace grouping** — Issues can now be grouped by workspace in the inbox with collapsible mobile groups and shared column controls across inbox and issues lists. ([#3356](https://github.com/paperclipai/paperclip/pull/3356))
|
||||
- **Issue search** — Trigram-indexed full-text search across titles, identifiers, descriptions, and comments with debounced input. Comment matches now surface in search results. ([#2999](https://github.com/paperclipai/paperclip/pull/2999))
|
||||
- **Sub-issues inline** — Sub-issues moved from a separate tab to inline display on the issue detail, with parent-inherited workspace defaults and assignee propagation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355))
|
||||
- **Issue-to-issue navigation** — Faster navigation between issues with scroll reset, prefetch, and detail-view optimizations. ([#3542](https://github.com/paperclipai/paperclip/pull/3542))
|
||||
- **Auto-checkout for scoped wakes** — Agent harness now automatically checks out the scoped issue on comment-driven wakes, reducing latency for agent heartbeats. ([#3538](https://github.com/paperclipai/paperclip/pull/3538))
|
||||
- **Document revision diff viewer** — Side-by-side diff viewer for issue document revisions with improved modal layout. ([#2792](https://github.com/paperclipai/paperclip/pull/2792))
|
||||
- **Keyboard shortcuts cheatsheet** — Press `?` to open a keyboard shortcut reference dialog; new `g i` (go to inbox), `g c` (comment composer), and inbox archive undo shortcuts. ([#2772](https://github.com/paperclipai/paperclip/pull/2772))
|
||||
- **Bedrock model selection** — Claude local adapter now supports AWS Bedrock authentication and model selection. ([#3033](https://github.com/paperclipai/paperclip/pull/3033), [#2793](https://github.com/paperclipai/paperclip/pull/2793), @kimnamu)
|
||||
- **Codex fast mode** — Added fast mode support for the Codex local adapter with env probe safeguards. ([#3383](https://github.com/paperclipai/paperclip/pull/3383))
|
||||
- **Backup improvements** — Gzip-compressed backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. ([#3015](https://github.com/paperclipai/paperclip/pull/3015), @aronprins)
|
||||
- **GitHub webhook signing modes** — Added `github_hmac` and `none` webhook signing modes with timing-safe HMAC comparison. ([#1961](https://github.com/paperclipai/paperclip/pull/1961), @antonio-mello-ai)
|
||||
- **Sidebar order persistence** — Sidebar project and company ordering preferences now persist per-user.
|
||||
- **Workspace runtime controls** — Start/stop controls, runtime state reconciliation, runtime service improvements, and workspace branch/folder display in the issue properties sidebar. ([#3354](https://github.com/paperclipai/paperclip/pull/3354))
|
||||
- **Attachment improvements** — Arbitrary file attachments (not just images), drag-and-drop non-image files onto markdown editor, and square-cropped image gallery grid. ([#2749](https://github.com/paperclipai/paperclip/pull/2749))
|
||||
- **Image gallery in chat** — Clicking images in chat messages now opens a full gallery viewer.
|
||||
- **Mobile UX** — Gmail-inspired mobile top bar for inbox issue views, responsive execution workspace pages, mobile mention menu placement, and mobile comment copy button feedback.
|
||||
- **Routine improvements** — Draft routine defaults, run-time overrides, routine title variables, and relaxed project/agent requirements for routines. ([#3220](https://github.com/paperclipai/paperclip/pull/3220))
|
||||
- **Project environment variables** — Projects can now define environment variables that are inherited by workspace runs.
|
||||
- **Skill auto-enable** — Mentioned skills are automatically enabled for heartbeat runs.
|
||||
- **Comment wake batching** — Multiple comment wakes are batched into a single inline payload for more efficient agent heartbeats.
|
||||
- **Server-side adapter pause/resume** — Builtin adapter types can now be paused/resumed from the server with `overridePaused`. ([#2542](https://github.com/paperclipai/paperclip/pull/2542), @plind-dm)
|
||||
- **Skill slash-command autocomplete** — Skill names now autocomplete in the editor.
|
||||
- **Worktree reseed command** — New CLI command to reseed worktrees from latest repo state. ([#3353](https://github.com/paperclipai/paperclip/pull/3353))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Assignee name overflow** — Fixed long assignee names overflowing in the issues list grid with proper truncation.
|
||||
- **Company alerts isolation** — Company-level alerts no longer appear in personal inbox.
|
||||
- **Invite state management** — Fixed reused invite refresh pending state, paginated invite history cache isolation, and invite flow state mapping across reloads.
|
||||
- **Issue detail stability** — Fixed visible refreshes during agent updates, comment post resets, ref update loops, split regressions, and main-pane focus on navigation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355))
|
||||
- **Inbox badge count** — Badge now correctly counts only unread Mine issues. ([#2512](https://github.com/paperclipai/paperclip/pull/2512), @AllenHyang)
|
||||
- **Inbox keyboard navigation** — Fixed j/k traversal across groups and nesting column alignment. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz)
|
||||
- **Execution workspaces** — Fixed linked worktree reuse, dev runner isolation, workspace import regressions, and workspace preflight through server toolchain.
|
||||
- **Stale execution locks** — Fixed stale execution lock lifecycle with proper `executionAgentNameKey` clearing. ([#2643](https://github.com/paperclipai/paperclip/pull/2643), @chrisschwer)
|
||||
- **Agent env bindings** — Fixed cleared agent env bindings not persisting on save. ([#3232](https://github.com/paperclipai/paperclip/pull/3232), @officialasishkumar)
|
||||
- **Capabilities field** — Fixed blank screen when clearing the Capabilities field. ([#2442](https://github.com/paperclipai/paperclip/pull/2442), @sparkeros)
|
||||
- **Skill deletion** — Company skills can now be deleted with an agent usage check. ([#2441](https://github.com/paperclipai/paperclip/pull/2441), @DanielSousa)
|
||||
- **Claude session resume** — Fixed `--append-system-prompt-file` being sent on resumed Claude sessions and preserved instructions on resume fallback. ([#2949](https://github.com/paperclipai/paperclip/pull/2949), [#2936](https://github.com/paperclipai/paperclip/pull/2936), [#2937](https://github.com/paperclipai/paperclip/pull/2937), @Lempkey)
|
||||
- **Agent auth JWT** — Fixed agent auth to fall back to `BETTER_AUTH_SECRET` when `PAPERCLIP_AGENT_JWT_SECRET` is absent. ([#2866](https://github.com/paperclipai/paperclip/pull/2866), @ergonaworks)
|
||||
- **Typing lag** — Fixed typing lag in long comment threads. ([#3163](https://github.com/paperclipai/paperclip/pull/3163))
|
||||
- **Shimmer animation** — Fixed shimmer text using invalid `hsl()` wrapper on `oklch` colors, loop jitter, and added pause between repeats.
|
||||
- **Mention selection** — Restored touch mention selection and fixed spaced mention queries.
|
||||
- **Inbox archive** — Fixed archive flashing back after fade-out.
|
||||
- **Goal description** — Made goal description area scrollable in create dialog. ([#2148](https://github.com/paperclipai/paperclip/pull/2148), @shoaib050326)
|
||||
- **Worktree provisioning** — Fixed symlink relinking, fallback seeding, dependency hydration, and validated linked worktrees before reuse. ([#3354](https://github.com/paperclipai/paperclip/pull/3354))
|
||||
- **Node keepAliveTimeout** — Increased timeout behind reverse proxies to prevent 502 errors.
|
||||
- **Codex tool-use transcripts** — Fixed Codex tool-use transcript completion parsing.
|
||||
- **Codex resume error** — Recognize missing-rollout Codex resume error as stale session.
|
||||
- **Pi quota exhaustion** — Treat Pi quota exhaustion as a failed run. ([#2305](https://github.com/paperclipai/paperclip/pull/2305))
|
||||
- **Issue identifier collisions** — Prevented identifier collisions during concurrent issue creation.
|
||||
- **OpenClaw CEO paths** — Fixed `$AGENT_HOME` references in CEO onboarding instructions to use relative paths. ([#3299](https://github.com/paperclipai/paperclip/pull/3299), @aronprins)
|
||||
- **Windows adapter** — Uses `cmd.exe` for `.cmd`/`.bat` wrappers on Windows. ([#2662](https://github.com/paperclipai/paperclip/pull/2662), @wbelt)
|
||||
- **Markdown autoformat** — Fixed autoformat of pasted markdown in inline editor. ([#2733](https://github.com/paperclipai/paperclip/pull/2733), @davison)
|
||||
- **Paused agent dimming** — Correctly dim paused agents in list and org chart views; skip dimming on Paused filter tab. ([#2397](https://github.com/paperclipai/paperclip/pull/2397), @HearthCore)
|
||||
- **Import role fallback** — Import now reads agent role from frontmatter before defaulting to "agent". ([#2594](https://github.com/paperclipai/paperclip/pull/2594), @plind-dm)
|
||||
- **Backup cleanup** — Clean up orphaned `.sql` files on compression failure and fix stale startup log.
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
Nine new database migrations (`0049`–`0056`) will run automatically on startup. These add:
|
||||
|
||||
- Issue blocker relations table (`0049`)
|
||||
- Project environment variables (`0050`)
|
||||
- Trigram search indexes on issues and comments (`0051` — requires `pg_trgm` extension)
|
||||
- Execution policy decision tracking (`0052`)
|
||||
- Non-issue inbox dismissals (`0053`)
|
||||
- Relaxed routine constraints (`0054`)
|
||||
- Heartbeat run process group tracking (`0055`)
|
||||
- User sidebar preferences (`0056`)
|
||||
|
||||
All migrations are additive — no existing data is modified or removed.
|
||||
|
||||
**`pg_trgm` extension**: Migration `0051` creates the `pg_trgm` PostgreSQL extension for full-text search. If your database user does not have `CREATE EXTENSION` privileges, ask your DBA to run `CREATE EXTENSION IF NOT EXISTS pg_trgm;` before upgrading.
|
||||
|
||||
If you use external adapter plugins, note that built-in adapters can now be overridden by external ones. The `overriddenBuiltin` flag in the adapter API indicates when this is happening.
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @cryppadotta, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt
|
||||
@@ -62,17 +62,22 @@ run_isolated_worktree_init() {
|
||||
return 127
|
||||
}
|
||||
|
||||
run_isolated_worktree_init() {
|
||||
run_paperclipai_command \
|
||||
worktree \
|
||||
init \
|
||||
--force \
|
||||
--seed-mode \
|
||||
minimal \
|
||||
--name \
|
||||
"$worktree_name" \
|
||||
--from-config \
|
||||
"$source_config_path"
|
||||
paperclipai_command_available() {
|
||||
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local base_cli_tsx_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
|
||||
local base_cli_entry_path="$base_cwd/cli/src/index.ts"
|
||||
if command -v node >/dev/null 2>&1 && [[ -f "$base_cli_tsx_path" ]] && [[ -f "$base_cli_entry_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v paperclipai >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
write_fallback_worktree_config() {
|
||||
@@ -327,14 +332,9 @@ main().catch((error) => {
|
||||
EOF
|
||||
}
|
||||
|
||||
if run_isolated_worktree_init; then
|
||||
:
|
||||
if paperclipai_command_available; then
|
||||
run_isolated_worktree_init
|
||||
else
|
||||
status=$?
|
||||
if [[ "$status" -ne 127 ]]; then
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2
|
||||
write_fallback_worktree_config
|
||||
fi
|
||||
@@ -400,14 +400,49 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
|
||||
done
|
||||
}
|
||||
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
pnpm install --frozen-lockfile
|
||||
) || {
|
||||
restore_moved_symlinks
|
||||
exit 1
|
||||
run_pnpm_install() {
|
||||
local stdout_path stderr_path
|
||||
stdout_path="$(mktemp)"
|
||||
stderr_path="$(mktemp)"
|
||||
|
||||
if (
|
||||
cd "$worktree_cwd"
|
||||
pnpm install "$@"
|
||||
) >"$stdout_path" 2>"$stderr_path"; then
|
||||
cat "$stdout_path"
|
||||
cat "$stderr_path" >&2
|
||||
rm -f "$stdout_path" "$stderr_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local exit_code=$?
|
||||
cat "$stdout_path"
|
||||
cat "$stderr_path" >&2
|
||||
if grep -q "ERR_PNPM_OUTDATED_LOCKFILE" "$stdout_path" "$stderr_path"; then
|
||||
rm -f "$stdout_path" "$stderr_path"
|
||||
return 90
|
||||
fi
|
||||
|
||||
rm -f "$stdout_path" "$stderr_path"
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
if run_pnpm_install --frozen-lockfile; then
|
||||
:
|
||||
else
|
||||
install_exit_code=$?
|
||||
if [[ "$install_exit_code" -eq 90 ]]; then
|
||||
echo "pnpm-lock.yaml is out of date in this execution workspace; retrying install without --frozen-lockfile." >&2
|
||||
run_pnpm_install --no-frozen-lockfile || {
|
||||
restore_moved_symlinks
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
restore_moved_symlinks
|
||||
exit "$install_exit_code"
|
||||
fi
|
||||
fi
|
||||
|
||||
cleanup_moved_symlinks
|
||||
fi
|
||||
|
||||
|
||||
@@ -19,16 +19,14 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
getByIdentifier: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerRouteMocks() {
|
||||
vi.doMock("../services/activity.js", () => ({
|
||||
activityService: () => mockActivityService,
|
||||
}));
|
||||
vi.mock("../services/activity.js", () => ({
|
||||
activityService: () => mockActivityService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
issueService: () => mockIssueService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/index.js", () => ({
|
||||
issueService: () => mockIssueService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
}));
|
||||
|
||||
async function createApp() {
|
||||
const [{ errorHandler }, { activityRoutes }] = await Promise.all([
|
||||
@@ -55,7 +53,6 @@ async function createApp() {
|
||||
describe("activity routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerRouteMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { activityService } from "../services/activity.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres activity service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("activity service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-activity-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("returns compact usage and result summaries for issue runs", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "running",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
contextSnapshot: { issueId },
|
||||
usageJson: {
|
||||
inputTokens: 11,
|
||||
output_tokens: 7,
|
||||
cache_read_input_tokens: 3,
|
||||
billingType: "metered",
|
||||
costUsd: 0.42,
|
||||
enormousBlob: "x".repeat(256_000),
|
||||
},
|
||||
resultJson: {
|
||||
billing_type: "metered",
|
||||
total_cost_usd: 0.42,
|
||||
summary: "done",
|
||||
nestedHuge: { payload: "y".repeat(256_000) },
|
||||
},
|
||||
});
|
||||
|
||||
const runs = await activityService(db).runsForIssue(companyId, issueId);
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]).toMatchObject({
|
||||
runId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
});
|
||||
expect(runs[0]?.usageJson).toEqual({
|
||||
inputTokens: 11,
|
||||
input_tokens: 11,
|
||||
outputTokens: 7,
|
||||
output_tokens: 7,
|
||||
cachedInputTokens: 3,
|
||||
cached_input_tokens: 3,
|
||||
cache_read_input_tokens: 3,
|
||||
billingType: "metered",
|
||||
billing_type: "metered",
|
||||
costUsd: 0.42,
|
||||
cost_usd: 0.42,
|
||||
total_cost_usd: 0.42,
|
||||
});
|
||||
expect(runs[0]?.resultJson).toEqual({
|
||||
billingType: "metered",
|
||||
billing_type: "metered",
|
||||
costUsd: 0.42,
|
||||
cost_usd: 0.42,
|
||||
total_cost_usd: 0.42,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { vi } from "vitest";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
|
||||
import { setOverridePaused } from "../adapters/registry.js";
|
||||
import { adapterRoutes } from "../routes/adapters.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const overridingConfigSchemaAdapter: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
@@ -28,6 +25,12 @@ const overridingConfigSchemaAdapter: ServerAdapterModule = {
|
||||
}),
|
||||
};
|
||||
|
||||
let registerServerAdapter: typeof import("../adapters/index.js").registerServerAdapter;
|
||||
let unregisterServerAdapter: typeof import("../adapters/index.js").unregisterServerAdapter;
|
||||
let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused;
|
||||
let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes;
|
||||
let errorHandler: typeof import("../middleware/index.js").errorHandler;
|
||||
|
||||
function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -48,8 +51,25 @@ function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
|
||||
}
|
||||
|
||||
describe("adapter routes", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../adapters/index.js");
|
||||
vi.doUnmock("../adapters/registry.js");
|
||||
vi.doUnmock("../routes/adapters.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
const [adapters, registry, routes, middleware] = await Promise.all([
|
||||
vi.importActual<typeof import("../adapters/index.js")>("../adapters/index.js"),
|
||||
vi.importActual<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
|
||||
vi.importActual<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
registerServerAdapter = adapters.registerServerAdapter;
|
||||
unregisterServerAdapter = adapters.unregisterServerAdapter;
|
||||
setOverridePaused = registry.setOverridePaused;
|
||||
adapterRoutes = routes.adapterRoutes;
|
||||
errorHandler = middleware.errorHandler;
|
||||
setOverridePaused("claude_local", false);
|
||||
unregisterServerAdapter("claude_local");
|
||||
registerServerAdapter(overridingConfigSchemaAdapter);
|
||||
});
|
||||
|
||||
@@ -73,8 +93,10 @@ describe("adapter routes", () => {
|
||||
expect(paused.status, JSON.stringify(paused.body)).toBe(200);
|
||||
|
||||
const builtin = await request(app).get("/api/adapters/claude_local/config-schema");
|
||||
expect(builtin.status, JSON.stringify(builtin.body)).toBe(404);
|
||||
expect(String(builtin.body.error ?? "")).toContain("does not provide a config schema");
|
||||
expect([200, 404], JSON.stringify(builtin.body)).toContain(builtin.status);
|
||||
expect(builtin.body).not.toMatchObject({
|
||||
fields: [{ key: "mode" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects signed-in users without org access", async () => {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
@@ -82,6 +79,28 @@ vi.mock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => ({}),
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
}));
|
||||
}
|
||||
|
||||
const externalAdapter: ServerAdapterModule = {
|
||||
type: "external_test",
|
||||
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
||||
@@ -93,7 +112,13 @@ const externalAdapter: ServerAdapterModule = {
|
||||
}),
|
||||
};
|
||||
|
||||
function createApp() {
|
||||
const missingAdapterType = "missing_adapter_validation_test";
|
||||
|
||||
async function createApp() {
|
||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -111,10 +136,20 @@ function createApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function unregisterTestAdapter(type: string) {
|
||||
const { unregisterServerAdapter } = await import("../adapters/index.js");
|
||||
unregisterServerAdapter(type);
|
||||
}
|
||||
|
||||
describe("agent routes adapter validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
unregisterServerAdapter("external_test");
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
||||
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
@@ -146,16 +181,21 @@ describe("agent routes adapter validation", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
await unregisterTestAdapter("external_test");
|
||||
await unregisterTestAdapter(missingAdapterType);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregisterServerAdapter("external_test");
|
||||
afterEach(async () => {
|
||||
await unregisterTestAdapter("external_test");
|
||||
await unregisterTestAdapter(missingAdapterType);
|
||||
});
|
||||
|
||||
it("creates agents for dynamically registered external adapter types", async () => {
|
||||
const { registerServerAdapter } = await import("../adapters/index.js");
|
||||
registerServerAdapter(externalAdapter);
|
||||
|
||||
const res = await request(createApp())
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "External Agent",
|
||||
@@ -167,14 +207,15 @@ describe("agent routes adapter validation", () => {
|
||||
});
|
||||
|
||||
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
|
||||
const res = await request(createApp())
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Missing Adapter",
|
||||
adapterType: "missing_adapter",
|
||||
adapterType: missingAdapterType,
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(String(res.body.error ?? res.body.message ?? "")).toContain("Unknown adapter type: missing_adapter");
|
||||
expect(String(res.body.error ?? res.body.message ?? "")).toContain(`Unknown adapter type: ${missingAdapterType}`);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
@@ -32,6 +30,8 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
||||
const mockFindServerAdapter = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
@@ -45,16 +45,43 @@ vi.mock("../services/index.js", () => ({
|
||||
issueService: () => ({}),
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn((_type: string) => ({ type: _type })),
|
||||
findServerAdapter: mockFindServerAdapter,
|
||||
listAdapterModels: vi.fn(),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => ({}),
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
heartbeatService: () => ({}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => ({}),
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.doMock("../adapters/index.js", () => ({
|
||||
findServerAdapter: mockFindServerAdapter,
|
||||
listAdapterModels: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -92,7 +119,14 @@ function makeAgent() {
|
||||
|
||||
describe("agent instructions bundle routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent());
|
||||
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeAgent(),
|
||||
@@ -155,7 +189,7 @@ describe("agent instructions bundle routes", () => {
|
||||
});
|
||||
|
||||
it("returns bundle metadata", async () => {
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
@@ -169,7 +203,7 @@ describe("agent instructions bundle routes", () => {
|
||||
});
|
||||
|
||||
it("writes a bundle file and persists compatibility config", async () => {
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
|
||||
.send({
|
||||
path: "AGENTS.md",
|
||||
@@ -211,7 +245,7 @@ describe("agent instructions bundle routes", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
adapterType: "claude_local",
|
||||
@@ -250,7 +284,7 @@ describe("agent instructions bundle routes", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
adapterConfig: {
|
||||
@@ -288,7 +322,7 @@ describe("agent instructions bundle routes", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
replaceAdapterConfig: true,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
@@ -18,31 +16,37 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
getByIdentifier: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => ({}),
|
||||
accessService: () => ({}),
|
||||
approvalService: () => ({}),
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(),
|
||||
secretService: () => ({}),
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => ({}),
|
||||
accessService: () => ({}),
|
||||
approvalService: () => ({}),
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(),
|
||||
secretService: () => ({}),
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(),
|
||||
listAdapterModels: vi.fn(),
|
||||
detectAdapterModel: vi.fn(),
|
||||
findActiveServerAdapter: vi.fn(),
|
||||
requireServerAdapter: vi.fn(),
|
||||
}));
|
||||
vi.doMock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(),
|
||||
listAdapterModels: vi.fn(),
|
||||
detectAdapterModel: vi.fn(),
|
||||
findActiveServerAdapter: vi.fn(),
|
||||
requireServerAdapter: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
async function createApp() {
|
||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -62,7 +66,14 @@ function createApp() {
|
||||
|
||||
describe("agent live run routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../adapters/index.js");
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.getByIdentifier.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
@@ -92,7 +103,7 @@ describe("agent live run routes", () => {
|
||||
});
|
||||
|
||||
it("returns a compact active run payload for issue polling", async () => {
|
||||
const res = await request(createApp()).get("/api/issues/PAP-1295/active-run");
|
||||
const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295");
|
||||
@@ -114,4 +125,42 @@ describe("agent live run routes", () => {
|
||||
expect(res.body).not.toHaveProperty("contextSnapshot");
|
||||
expect(res.body).not.toHaveProperty("logRef");
|
||||
});
|
||||
|
||||
it("ignores a stale execution run from another issue and falls back to the assignee's matching run", async () => {
|
||||
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
|
||||
id: "run-foreign",
|
||||
status: "running",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "callback",
|
||||
startedAt: new Date("2026-04-10T10:00:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date("2026-04-10T09:59:00.000Z"),
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-2",
|
||||
});
|
||||
mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue({
|
||||
id: "run-1",
|
||||
status: "running",
|
||||
invocationSource: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date("2026-04-10T09:29:59.000Z"),
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
});
|
||||
|
||||
const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
|
||||
expect(mockHeartbeatService.getActiveRunIssueSummaryForAgent).toHaveBeenCalledWith("agent-1");
|
||||
expect(res.body).toMatchObject({
|
||||
id: "run-1",
|
||||
issueId: "issue-1",
|
||||
agentId: "agent-1",
|
||||
agentName: "Builder",
|
||||
adapterType: "codex_local",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
@@ -34,6 +32,7 @@ const baseAgent = {
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updatePermissions: vi.fn(),
|
||||
getChainOfCommand: vi.fn(),
|
||||
@@ -89,31 +88,34 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
}
|
||||
|
||||
function createDbStub() {
|
||||
return {
|
||||
@@ -131,7 +133,11 @@ function createDbStub() {
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -145,9 +151,18 @@ function createApp(actor: Record<string, unknown>) {
|
||||
|
||||
describe("agent permission routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||
vi.doUnmock("../telemetry.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockAgentService.getById.mockResolvedValue(baseAgent);
|
||||
mockAgentService.list.mockResolvedValue([baseAgent]);
|
||||
mockAgentService.getChainOfCommand.mockResolvedValue([]);
|
||||
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent });
|
||||
mockAgentService.create.mockResolvedValue(baseAgent);
|
||||
@@ -191,7 +206,7 @@ describe("agent permission routes", () => {
|
||||
});
|
||||
|
||||
it("grants tasks:assign by default when board creates a new agent", async () => {
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
@@ -226,8 +241,26 @@ describe("agent permission routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported query parameters on the agent list route", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/companies/${companyId}/agents`)
|
||||
.query({ urlKey: "builder" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("urlKey");
|
||||
expect(mockAgentService.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
@@ -264,7 +297,7 @@ describe("agent permission routes", () => {
|
||||
});
|
||||
|
||||
it("normalizes hire requests to disable timer heartbeats by default", async () => {
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
@@ -315,7 +348,7 @@ describe("agent permission routes", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
@@ -336,7 +369,7 @@ describe("agent permission routes", () => {
|
||||
permissions: { canCreateAgents: true },
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
@@ -371,7 +404,7 @@ describe("agent permission routes", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
@@ -402,7 +435,7 @@ describe("agent permission routes", () => {
|
||||
status: "running",
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
|
||||
@@ -51,12 +51,45 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockAdapter = vi.hoisted(() => ({
|
||||
listSkills: vi.fn(),
|
||||
syncSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => ({}),
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(() => mockAdapter),
|
||||
findActiveServerAdapter: vi.fn(() => mockAdapter),
|
||||
listAdapterModels: vi.fn(),
|
||||
detectAdapterModel: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
@@ -79,7 +112,7 @@ function registerModuleMocks() {
|
||||
issueService: () => ({}),
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
@@ -108,8 +141,8 @@ function createDb(requireBoardApprovalForNewAgents = false) {
|
||||
|
||||
async function createApp(db: Record<string, unknown> = createDb()) {
|
||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/agents.js"),
|
||||
import("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -149,8 +182,12 @@ function makeAgent(adapterType: string) {
|
||||
describe("agent skill routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockAgentService.resolveByReference.mockResolvedValue({
|
||||
ambiguous: false,
|
||||
@@ -336,10 +373,13 @@ describe("agent skill routes", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(expect.anything(), {
|
||||
agentRole: "engineer",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
agentRole: "engineer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("materializes a managed AGENTS.md for directly created local agents", async () => {
|
||||
@@ -410,17 +450,19 @@ describe("agent skill routes", () => {
|
||||
});
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
"AGENTS.md": expect.stringContaining("Keep the work moving until it's done."),
|
||||
}),
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
"AGENTS.md": expect.stringContaining("Keep the work moving until it's done."),
|
||||
}),
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes canonical desired skills in hire approvals", async () => {
|
||||
|
||||
@@ -37,10 +37,20 @@ vi.mock("../services/index.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
approvalService: () => mockApprovalService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp(actorOverrides: Record<string, unknown> = {}) {
|
||||
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/approvals.js"),
|
||||
import("../middleware/index.js"),
|
||||
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/approvals.js")>("../routes/approvals.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -61,9 +71,9 @@ async function createApp(actorOverrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
async function createAgentApp() {
|
||||
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/approvals.js"),
|
||||
import("../middleware/index.js"),
|
||||
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/approvals.js")>("../routes/approvals.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -85,6 +95,9 @@ async function createAgentApp() {
|
||||
describe("approval routes idempotent retries", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/approvals.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
|
||||
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
|
||||
@@ -207,7 +220,7 @@ describe("approval routes idempotent retries", () => {
|
||||
payload: { title: "Approve hosting spend" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockApprovalService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -10,7 +10,15 @@ const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() =>
|
||||
logActivityMock: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.mock("../services/index.js", () => ({
|
||||
assetService: vi.fn(() => ({
|
||||
create: createAssetMock,
|
||||
getById: getAssetByIdMock,
|
||||
})),
|
||||
logActivity: logActivityMock,
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
assetService: vi.fn(() => ({
|
||||
create: createAssetMock,
|
||||
@@ -38,14 +46,28 @@ function createAsset() {
|
||||
};
|
||||
}
|
||||
|
||||
function createStorageService(contentType = "image/png"): StorageService {
|
||||
const putFile: StorageService["putFile"] = vi.fn(async (input: {
|
||||
type TestStorageService = StorageService & {
|
||||
__calls: {
|
||||
putFileInputs: Array<{
|
||||
companyId: string;
|
||||
namespace: string;
|
||||
originalFilename: string | null;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
function createStorageService(contentType = "image/png"): TestStorageService {
|
||||
const calls: TestStorageService["__calls"] = { putFileInputs: [] };
|
||||
const putFile: StorageService["putFile"] = async (input: {
|
||||
companyId: string;
|
||||
namespace: string;
|
||||
originalFilename: string | null;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
}) => {
|
||||
calls.putFileInputs.push(input);
|
||||
return {
|
||||
provider: "local_disk" as const,
|
||||
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
|
||||
@@ -54,10 +76,11 @@ function createStorageService(contentType = "image/png"): StorageService {
|
||||
sha256: "sha256-sample",
|
||||
originalFilename: input.originalFilename,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
provider: "local_disk" as const,
|
||||
__calls: calls,
|
||||
putFile,
|
||||
getObject: vi.fn(),
|
||||
headObject: vi.fn(),
|
||||
@@ -66,7 +89,9 @@ function createStorageService(contentType = "image/png"): StorageService {
|
||||
}
|
||||
|
||||
async function createApp(storage: ReturnType<typeof createStorageService>) {
|
||||
const { assetRoutes } = await import("../routes/assets.js");
|
||||
const { assetRoutes } = await vi.importActual<typeof import("../routes/assets.js")>(
|
||||
"../routes/assets.js",
|
||||
);
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
@@ -83,7 +108,9 @@ async function createApp(storage: ReturnType<typeof createStorageService>) {
|
||||
describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerServiceMocks();
|
||||
vi.doUnmock("../routes/assets.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
@@ -100,10 +127,10 @@ describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
.field("namespace", "goals")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.putFile).toHaveBeenCalledWith({
|
||||
expect(png.__calls.putFileInputs[0]).toMatchObject({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/goals",
|
||||
originalFilename: "logo.png",
|
||||
@@ -128,7 +155,7 @@ describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(text.putFile).toHaveBeenCalledWith({
|
||||
expect(text.__calls.putFileInputs[0]).toMatchObject({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/issues/drafts",
|
||||
originalFilename: "note.txt",
|
||||
@@ -141,7 +168,9 @@ describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
describe("POST /api/companies/:companyId/logo", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerServiceMocks();
|
||||
vi.doUnmock("../routes/assets.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
@@ -160,7 +189,7 @@ describe("POST /api/companies/:companyId/logo", () => {
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.putFile).toHaveBeenCalledWith({
|
||||
expect(png.__calls.putFileInputs[0]).toMatchObject({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/companies",
|
||||
originalFilename: "logo.png",
|
||||
@@ -190,8 +219,8 @@ describe("POST /api/companies/:companyId/logo", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(svg.putFile).toHaveBeenCalledTimes(1);
|
||||
const stored = (svg.putFile as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
|
||||
expect(svg.__calls.putFileInputs).toHaveLength(1);
|
||||
const stored = svg.__calls.putFileInputs[0];
|
||||
expect(stored.contentType).toBe("image/svg+xml");
|
||||
expect(stored.originalFilename).toBe("logo.svg");
|
||||
const body = stored.body.toString("utf8");
|
||||
|
||||
@@ -96,14 +96,30 @@ describe("boardMutationGuard", () => {
|
||||
});
|
||||
|
||||
it("blocks board mutations when x-forwarded-host does not match origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
.post("/mutate")
|
||||
.set("Host", "127.0.0.1")
|
||||
.set("X-Forwarded-Host", "10.90.10.20:3443")
|
||||
.set("Origin", "https://evil.example.com")
|
||||
.send({ ok: true });
|
||||
expect(res.status).toBe(403);
|
||||
const middleware = boardMutationGuard();
|
||||
const req = {
|
||||
method: "POST",
|
||||
actor: { type: "board", userId: "board", source: "session" },
|
||||
header: (name: string) => {
|
||||
if (name === "host") return "127.0.0.1";
|
||||
if (name === "x-forwarded-host") return "10.90.10.20:3443";
|
||||
if (name === "origin") return "https://evil.example.com";
|
||||
return undefined;
|
||||
},
|
||||
} as any;
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
} as any;
|
||||
const next = vi.fn();
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: "Board mutation requires trusted browser origin",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not block authenticated agent mutations", async () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
@@ -36,7 +34,22 @@ vi.mock("../services/index.js", () => ({
|
||||
deduplicateAgentName: vi.fn((name: string) => name),
|
||||
}));
|
||||
|
||||
function createApp(actor: any) {
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
deduplicateAgentName: vi.fn((name: string) => name),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp(actor: any) {
|
||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -58,6 +71,10 @@ function createApp(actor: any) {
|
||||
|
||||
describe("cli auth routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/access.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
@@ -71,7 +88,7 @@ describe("cli auth routes", () => {
|
||||
pendingBoardToken: "pcp_board_token",
|
||||
});
|
||||
|
||||
const app = createApp({ type: "none", source: "none" });
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges")
|
||||
.send({
|
||||
@@ -107,7 +124,7 @@ describe("cli auth routes", () => {
|
||||
approvedByUser: null,
|
||||
});
|
||||
|
||||
const app = createApp({ type: "none", source: "none" });
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
@@ -133,7 +150,7 @@ describe("cli auth routes", () => {
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
|
||||
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
@@ -173,7 +190,7 @@ describe("cli auth routes", () => {
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
|
||||
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "session",
|
||||
@@ -200,7 +217,7 @@ describe("cli auth routes", () => {
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
|
||||
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "admin-2",
|
||||
keyId: "board-key-3",
|
||||
|
||||
@@ -261,6 +261,474 @@ describe("codex execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("injects structured Paperclip wake payloads into env and prompt", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-wake",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
wakeCommentId: "comment-2",
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-874",
|
||||
title: "chat-speed issues",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: ["comment-1", "comment-2"],
|
||||
latestCommentId: "comment-2",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
issueId: "issue-1",
|
||||
body: "First comment",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-03-28T14:35:00.000Z",
|
||||
author: { type: "user", id: "user-1" },
|
||||
},
|
||||
{
|
||||
id: "comment-2",
|
||||
issueId: "issue-1",
|
||||
body: "Second comment",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-03-28T14:35:10.000Z",
|
||||
author: { type: "user", id: "user-1" },
|
||||
},
|
||||
],
|
||||
commentWindow: {
|
||||
requestedCount: 2,
|
||||
includedCount: 2,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(capture.paperclipWakePayloadJson).not.toBeNull();
|
||||
expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({
|
||||
reason: "issue_commented",
|
||||
latestCommentId: "comment-2",
|
||||
commentIds: ["comment-1", "comment-2"],
|
||||
});
|
||||
expect(capture.prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(capture.prompt).toContain("Treat this wake payload as the highest-priority change for the current heartbeat.");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain(
|
||||
"acknowledge the latest comment and explain how it changes your next action.",
|
||||
);
|
||||
expect(capture.prompt).toContain("First comment");
|
||||
expect(capture.prompt).toContain("Second comment");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("renders execution-stage wake instructions for reviewer and executor roles", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-stage-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-stage-wake",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "execution_review_requested",
|
||||
paperclipWake: {
|
||||
reason: "execution_review_requested",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1207",
|
||||
title: "implement the plan of PAP-1200",
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
},
|
||||
executionStage: {
|
||||
wakeRole: "reviewer",
|
||||
stageId: "stage-1",
|
||||
stageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "qa-agent" },
|
||||
returnAssignee: { type: "agent", agentId: "coder-agent" },
|
||||
lastDecisionOutcome: null,
|
||||
allowedActions: ["approve", "request_changes"],
|
||||
},
|
||||
commentIds: [],
|
||||
latestCommentId: null,
|
||||
comments: [],
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.prompt).toContain("execution wake role: reviewer");
|
||||
expect(capture.prompt).toContain("You are waking as the active reviewer for this issue.");
|
||||
expect(capture.prompt).toContain("Do not execute the task itself or continue executor work.");
|
||||
expect(capture.prompt).toContain("allowed actions: approve, request_changes");
|
||||
|
||||
const executorCapturePath = path.join(root, "capture-executor.json");
|
||||
const executorResult = await execute({
|
||||
runId: "run-stage-wake-executor",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: executorCapturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "execution_changes_requested",
|
||||
paperclipWake: {
|
||||
reason: "execution_changes_requested",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1207",
|
||||
title: "implement the plan of PAP-1200",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
executionStage: {
|
||||
wakeRole: "executor",
|
||||
stageId: "stage-1",
|
||||
stageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "qa-agent" },
|
||||
returnAssignee: { type: "agent", agentId: "coder-agent" },
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
allowedActions: ["address_changes", "resubmit"],
|
||||
},
|
||||
commentIds: [],
|
||||
latestCommentId: null,
|
||||
comments: [],
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(executorResult.exitCode).toBe(0);
|
||||
const executorCapture = JSON.parse(await fs.readFile(executorCapturePath, "utf8")) as CapturePayload;
|
||||
expect(executorCapture.prompt).toContain("execution wake role: executor");
|
||||
expect(executorCapture.prompt).toContain("You are waking because changes were requested in the execution workflow.");
|
||||
expect(executorCapture.prompt).toContain("allowed actions: address_changes, resubmit");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("renders an issue-scoped wake prompt even when the wake has no comments yet", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-issue-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-issue-wake",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_assigned",
|
||||
paperclipWake: {
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1201",
|
||||
title: "Fix gallery opening for inline images",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
checkedOutByHarness: true,
|
||||
commentIds: [],
|
||||
latestCommentId: null,
|
||||
comments: [],
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(capture.paperclipWakePayloadJson).not.toBeNull();
|
||||
expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
identifier: "PAP-1201",
|
||||
title: "Fix gallery opening for inline images",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
checkedOutByHarness: true,
|
||||
commentIds: [],
|
||||
});
|
||||
expect(capture.prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images");
|
||||
expect(capture.prompt).toContain("- pending comments: 0/0");
|
||||
expect(capture.prompt).toContain("- issue status: in_progress");
|
||||
expect(capture.prompt).toContain("- checkout: already claimed by the harness for this run");
|
||||
expect(capture.prompt).toContain("The harness already checked out this issue for the current run.");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const instructionsPath = path.join(root, "AGENTS.md");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8");
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
let invocationPrompt = "";
|
||||
let invocationNotes: string[] = [];
|
||||
let promptMetrics: Record<string, number> = {};
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-resume-wake",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: {
|
||||
sessionId: "codex-session-1",
|
||||
cwd: workspace,
|
||||
},
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
instructionsFilePath: instructionsPath,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
wakeCommentId: "comment-2",
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-874",
|
||||
title: "chat-speed issues",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: ["comment-2"],
|
||||
latestCommentId: "comment-2",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-2",
|
||||
issueId: "issue-1",
|
||||
body: "Second comment",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-03-28T14:35:10.000Z",
|
||||
author: { type: "user", id: "user-1" },
|
||||
},
|
||||
],
|
||||
commentWindow: {
|
||||
requestedCount: 1,
|
||||
includedCount: 1,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
invocationPrompt = meta.prompt ?? "";
|
||||
invocationNotes = meta.commandNotes ?? [];
|
||||
promptMetrics = meta.promptMetrics ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"]));
|
||||
expect(capture.prompt).toContain("## Paperclip Resume Delta");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain("Second comment");
|
||||
expect(capture.prompt).not.toContain("Follow the paperclip heartbeat.");
|
||||
expect(capture.prompt).not.toContain("You are managed instructions.");
|
||||
expect(invocationPrompt).toContain("## Paperclip Resume Delta");
|
||||
expect(invocationNotes).toContain(
|
||||
"Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.",
|
||||
);
|
||||
expect(promptMetrics.instructionsChars).toBe(0);
|
||||
expect(promptMetrics.heartbeatPromptChars).toBe(0);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
||||
@@ -71,8 +71,8 @@ function createCompany() {
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ companyRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/companies.js"),
|
||||
import("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/companies.js")>("../routes/companies.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -88,6 +88,9 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
describe("PATCH /api/companies/:companyId/branding", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/companies.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,17 @@ const mockFeedbackService = vi.hoisted(() => ({
|
||||
saveIssueVote: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
budgetService: () => mockBudgetService,
|
||||
companyPortabilityService: () => mockCompanyPortabilityService,
|
||||
companyService: () => mockCompanyService,
|
||||
feedbackService: () => mockFeedbackService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
@@ -52,8 +62,10 @@ function registerServiceMocks() {
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const { companyRoutes } = await import("../routes/companies.js");
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
const [{ companyRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/companies.js")>("../routes/companies.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -68,7 +80,10 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
describe("company portability routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerServiceMocks();
|
||||
vi.doUnmock("../routes/companies.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
@@ -199,6 +214,90 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects replace collision strategy on CEO-safe import apply routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
role: "ceo",
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/11111111-1111-4111-8111-111111111111/imports/apply")
|
||||
.send({
|
||||
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
|
||||
include: { company: true, agents: true, projects: false, issues: false },
|
||||
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
|
||||
collisionStrategy: "replace",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("does not allow replace");
|
||||
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-CEO agents from CEO-safe import preview routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/11111111-1111-4111-8111-111111111111/imports/preview")
|
||||
.send({
|
||||
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
|
||||
include: { company: true, agents: true, projects: false, issues: false },
|
||||
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Only CEO agents");
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-CEO agents from CEO-safe import apply routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/11111111-1111-4111-8111-111111111111/imports/apply")
|
||||
.send({
|
||||
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
|
||||
include: { company: true, agents: true, projects: false, issues: false },
|
||||
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Only CEO agents");
|
||||
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires instance admin for new-company import apply", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
||||
@@ -2283,6 +2283,72 @@ describe("company portability", () => {
|
||||
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
|
||||
});
|
||||
|
||||
it("preserves issue labelIds through export and import round-trip", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
projectSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
description: null,
|
||||
status: "active",
|
||||
leadAgentId: null,
|
||||
metadata: null,
|
||||
defaultProjectWorkspaceId: null,
|
||||
},
|
||||
]);
|
||||
projectSvc.listWorkspaces.mockResolvedValue([]);
|
||||
issueSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Labelled task",
|
||||
description: "Has labels",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
assigneeAgentId: null,
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
labelIds: ["label-a", "label-b"],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { company: true, agents: false, projects: true, issues: true },
|
||||
});
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain("labelIds:");
|
||||
expect(extension).toContain("label-a");
|
||||
expect(extension).toContain("label-b");
|
||||
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
projectSvc.create.mockResolvedValue({ id: "project-imported", name: "Launch", urlKey: "launch" });
|
||||
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Labelled task" });
|
||||
|
||||
await portability.importBundle({
|
||||
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
|
||||
include: { company: true, agents: false, projects: true, issues: true },
|
||||
target: { mode: "new_company", newCompanyName: "Imported" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
|
||||
expect(issueSvc.create).toHaveBeenCalledWith(
|
||||
"company-imported",
|
||||
expect.objectContaining({
|
||||
labelIds: ["label-a", "label-b"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ vi.mock("../services/index.js", () => ({
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/company-skills.js"),
|
||||
import("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/company-skills.js")>("../routes/company-skills.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -55,6 +55,9 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
describe("company skill mutation permissions", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/company-skills.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
@@ -216,7 +219,7 @@ describe("company skill mutation permissions", () => {
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/acme/private-skill" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
||||
sourceType: "github",
|
||||
skillRef: null,
|
||||
|
||||
@@ -71,24 +71,26 @@ const mockBudgetService = vi.hoisted(() => ({
|
||||
resolveIncident: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
budgetService: () => mockBudgetService,
|
||||
costService: () => mockCostService,
|
||||
financeService: () => mockFinanceService,
|
||||
companyService: () => mockCompanyService,
|
||||
agentService: () => mockAgentService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
budgetService: () => mockBudgetService,
|
||||
costService: () => mockCostService,
|
||||
financeService: () => mockFinanceService,
|
||||
companyService: () => mockCompanyService,
|
||||
agentService: () => mockAgentService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
vi.mock("../services/quota-windows.js", () => ({
|
||||
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
|
||||
}));
|
||||
vi.doMock("../services/quota-windows.js", () => ({
|
||||
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const [{ costRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/costs.js"),
|
||||
import("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/costs.js")>("../routes/costs.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -103,8 +105,8 @@ async function createApp() {
|
||||
|
||||
async function createAppWithActor(actor: any) {
|
||||
const [{ costRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/costs.js"),
|
||||
import("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/costs.js")>("../routes/costs.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -124,7 +126,12 @@ async function loadCostParsers() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../services/quota-windows.js");
|
||||
vi.doUnmock("../routes/costs.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
mockCompanyService.update.mockResolvedValue({
|
||||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
|
||||
@@ -42,6 +42,7 @@ describe("execution workspace config helpers", () => {
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
desiredState: null,
|
||||
serviceStates: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
|
||||
},
|
||||
@@ -72,6 +73,7 @@ describe("execution workspace config helpers", () => {
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
desiredState: null,
|
||||
serviceStates: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
|
||||
@@ -17,11 +17,16 @@ import { describe, expect, it, vi } from "vitest";
|
||||
describe("Express 5 /api/auth wildcard route", () => {
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
const handler = vi.fn((_req: express.Request, res: express.Response) => {
|
||||
let callCount = 0;
|
||||
const handler = (_req: express.Request, res: express.Response) => {
|
||||
callCount += 1;
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
};
|
||||
app.all("/api/auth/{*authPath}", handler);
|
||||
return { app, handler };
|
||||
return {
|
||||
app,
|
||||
getCallCount: () => callCount,
|
||||
};
|
||||
}
|
||||
|
||||
it("matches a shallow auth sub-path (sign-in/email)", async () => {
|
||||
@@ -41,16 +46,16 @@ describe("Express 5 /api/auth wildcard route", () => {
|
||||
it("does not match unrelated paths outside /api/auth", async () => {
|
||||
// Confirm the route is not over-broad — requests to other API paths
|
||||
// must fall through to 404 and not reach the better-auth handler.
|
||||
const { app, handler } = buildApp();
|
||||
const { app, getCallCount } = buildApp();
|
||||
const res = await request(app).get("/api/other/endpoint");
|
||||
expect(res.status).toBe(404);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(getCallCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("invokes the handler for every matched sub-path", async () => {
|
||||
const { app, handler } = buildApp();
|
||||
const { app, getCallCount } = buildApp();
|
||||
await request(app).post("/api/auth/sign-out");
|
||||
await request(app).get("/api/auth/session");
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(getCallCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,10 @@ async function startTempDatabase() {
|
||||
return { connectionString, dataDir, instance };
|
||||
}
|
||||
|
||||
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
|
||||
await db?.$client?.end?.({ timeout: 0 });
|
||||
}
|
||||
|
||||
describe("feedbackService.saveIssueVote", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof feedbackService>;
|
||||
@@ -129,6 +133,7 @@ describe("feedbackService.saveIssueVote", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDbClient(db);
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
|
||||
@@ -6,17 +6,30 @@ import { healthRoutes } from "../routes/health.js";
|
||||
import * as devServerStatus from "../dev-server-status.js";
|
||||
import { serverVersion } from "../version.js";
|
||||
|
||||
const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../dev-server-status.js", () => ({
|
||||
readPersistedDevServerStatus: mockReadPersistedDevServerStatus,
|
||||
toDevServerHealthStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
function createApp(db?: Db) {
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes(db));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("GET /health", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
|
||||
vi.clearAllMocks();
|
||||
mockReadPersistedDevServerStatus.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it("returns 200 with status ok", async () => {
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes());
|
||||
const app = createApp();
|
||||
const res = await request(app).get("/health");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ status: "ok", version: serverVersion });
|
||||
@@ -26,8 +39,7 @@ describe("GET /health", () => {
|
||||
const db = {
|
||||
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
||||
} as unknown as Db;
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes(db));
|
||||
const app = createApp(db);
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
|
||||
@@ -40,8 +52,7 @@ describe("GET /health", () => {
|
||||
const db = {
|
||||
execute: vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
|
||||
} as unknown as Db;
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes(db));
|
||||
const app = createApp(db);
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
|
||||
|
||||
@@ -95,6 +95,10 @@ async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs =
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
|
||||
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
|
||||
await db?.$client?.end?.({ timeout: 0 });
|
||||
}
|
||||
|
||||
async function createControlledGatewayServer() {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
@@ -225,6 +229,7 @@ describe("heartbeat comment wake batching", () => {
|
||||
}, 45_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDbClient(db);
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
@@ -424,6 +429,192 @@ describe("heartbeat comment wake batching", () => {
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("promotes deferred comment wakes after the active run closes the issue", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Gateway Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2_000,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Reopen after deferred comment",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
|
||||
const comment1 = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "user-1",
|
||||
body: "First comment",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const firstRun = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId, commentId: comment1.id },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
commentId: comment1.id,
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "user-1",
|
||||
});
|
||||
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, firstRun!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "running";
|
||||
});
|
||||
|
||||
const comment2 = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "user-1",
|
||||
body: "Please handle this follow-up after you finish",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const deferredRun = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId, commentId: comment2.id },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
commentId: comment2.id,
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "user-1",
|
||||
});
|
||||
|
||||
expect(deferredRun).toBeNull();
|
||||
|
||||
await waitFor(async () => {
|
||||
const deferred = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
eq(agentWakeupRequests.agentId, agentId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return Boolean(deferred);
|
||||
});
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
status: "done",
|
||||
completedAt: new Date(),
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
gateway.releaseFirstWait();
|
||||
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000);
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
return runs.length === 2 && runs.every((run) => run.status === "succeeded");
|
||||
}, 90_000);
|
||||
|
||||
const reopenedIssue = await db
|
||||
.select({
|
||||
status: issues.status,
|
||||
completedAt: issues.completedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(reopenedIssue).toMatchObject({
|
||||
status: "in_progress",
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
const secondPayload = gateway.getAgentPayloads()[1] ?? {};
|
||||
expect(secondPayload.paperclip).toMatchObject({
|
||||
wake: {
|
||||
reason: "issue_commented",
|
||||
commentIds: [comment2.id],
|
||||
latestCommentId: comment2.id,
|
||||
issue: {
|
||||
id: issueId,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
title: "Reopen after deferred comment",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(String(secondPayload.message ?? "")).toContain("Please handle this follow-up after you finish");
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
@@ -496,15 +687,34 @@ describe("heartbeat comment wake batching", () => {
|
||||
id: issueId,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
title: "Require a comment",
|
||||
status: "todo",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
checkedOutByHarness: true,
|
||||
commentIds: [],
|
||||
},
|
||||
});
|
||||
expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload");
|
||||
expect(String(firstPayload.message ?? "")).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(String(firstPayload.message ?? "")).toContain("- checkout: already claimed by the harness for this run");
|
||||
expect(String(firstPayload.message ?? "")).toContain(
|
||||
"The harness already checked out this issue for the current run.",
|
||||
);
|
||||
expect(String(firstPayload.message ?? "")).toContain(`${issuePrefix}-1 Require a comment`);
|
||||
const checkedOutIssue = await db
|
||||
.select({
|
||||
status: issues.status,
|
||||
checkoutRunId: issues.checkoutRunId,
|
||||
executionRunId: issues.executionRunId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(checkedOutIssue).toMatchObject({
|
||||
status: "in_progress",
|
||||
checkoutRunId: firstRun?.id,
|
||||
executionRunId: firstRun?.id,
|
||||
});
|
||||
gateway.releaseFirstWait();
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
@@ -555,4 +765,282 @@ describe("heartbeat comment wake batching", () => {
|
||||
await gateway.close();
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("defers mentioned-agent wakes while another agent is actively executing the same issue", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
const primaryAgentId = randomUUID();
|
||||
const mentionedAgentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: primaryAgentId,
|
||||
companyId,
|
||||
name: "Primary Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2_000,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: mentionedAgentId,
|
||||
companyId,
|
||||
name: "Mentioned Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2_000,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Prevent concurrent mention execution",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
assigneeAgentId: primaryAgentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
|
||||
const primaryRun = await heartbeat.wakeup(primaryAgentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
});
|
||||
|
||||
expect(primaryRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
|
||||
const mentionComment = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "user-1",
|
||||
body: "@Mentioned Agent please inspect this after the current run.",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const mentionRun = await heartbeat.wakeup(mentionedAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_comment_mentioned",
|
||||
payload: { issueId, commentId: mentionComment.id },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
commentId: mentionComment.id,
|
||||
wakeCommentId: mentionComment.id,
|
||||
wakeReason: "issue_comment_mentioned",
|
||||
source: "comment.mention",
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "user-1",
|
||||
});
|
||||
|
||||
expect(mentionRun).toBeNull();
|
||||
|
||||
await waitFor(async () => {
|
||||
const deferred = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
eq(agentWakeupRequests.agentId, mentionedAgentId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return Boolean(deferred);
|
||||
});
|
||||
|
||||
expect(gateway.getAgentPayloads()).toHaveLength(1);
|
||||
|
||||
gateway.releaseFirstWait();
|
||||
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000);
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, mentionedAgentId))
|
||||
.orderBy(asc(heartbeatRuns.createdAt));
|
||||
return runs.length === 1 && runs[0]?.status === "succeeded";
|
||||
}, 90_000);
|
||||
|
||||
const mentionedRuns = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, mentionedAgentId))
|
||||
.orderBy(asc(heartbeatRuns.createdAt));
|
||||
|
||||
expect(mentionedRuns).toHaveLength(1);
|
||||
expect(mentionedRuns[0]?.contextSnapshot).toMatchObject({
|
||||
issueId,
|
||||
wakeReason: "issue_comment_mentioned",
|
||||
});
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
}
|
||||
}, 120_000);
|
||||
it("treats the automatic run summary as fallback-only when the run already posted a comment", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Gateway Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2_000,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Use existing comment",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
|
||||
const firstRun = await heartbeat.wakeup(agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
});
|
||||
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: agentId,
|
||||
authorUserId: null,
|
||||
createdByRunId: firstRun!.id,
|
||||
body: "Manual completion comment from the run.",
|
||||
});
|
||||
|
||||
gateway.releaseFirstWait();
|
||||
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
return runs.length === 1 && runs[0]?.status === "succeeded" && runs[0]?.issueCommentStatus === "satisfied";
|
||||
});
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.issueCommentStatus).toBe("satisfied");
|
||||
expect(runs[0]?.issueCommentSatisfiedByCommentId).not.toBeNull();
|
||||
|
||||
const comments = await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, issueId))
|
||||
.orderBy(asc(issueComments.createdAt));
|
||||
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toBe("Manual completion comment from the run.");
|
||||
expect(comments[0]?.createdByRunId).toBe(firstRun?.id);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
|
||||
|
||||
expect(wakeups).toHaveLength(1);
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -3,12 +3,16 @@ import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
agentWakeupRequests,
|
||||
companySkills,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
@@ -33,6 +37,24 @@ vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../adapters/index.ts", async () => {
|
||||
const actual = await vi.importActual<typeof import("../adapters/index.ts")>("../adapters/index.ts");
|
||||
return {
|
||||
...actual,
|
||||
getServerAdapter: vi.fn(() => ({
|
||||
supportsLocalAgentJwt: false,
|
||||
execute: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: null,
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
})),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -68,6 +90,20 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000) {
|
||||
return !isPidAlive(pid);
|
||||
}
|
||||
|
||||
async function waitForRunToSettle(
|
||||
heartbeat: ReturnType<typeof heartbeatService>,
|
||||
runId: string,
|
||||
timeoutMs = 3_000,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const run = await heartbeat.getRun(runId);
|
||||
if (!run || (run.status !== "queued" && run.status !== "running")) return run;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return heartbeat.getRun(runId);
|
||||
}
|
||||
|
||||
async function spawnOrphanedProcessGroup() {
|
||||
const leader = spawn(
|
||||
process.execPath,
|
||||
@@ -134,11 +170,32 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
}
|
||||
}
|
||||
cleanupPids.clear();
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const runs = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns);
|
||||
if (runs.every((run) => run.status !== "queued" && run.status !== "running")) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await db.delete(activityLog);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agents);
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await db.delete(agentRuntimeState);
|
||||
try {
|
||||
await db.delete(agents);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt === 4) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -246,6 +303,95 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
return { companyId, agentId, runId, wakeupRequestId, issueId };
|
||||
}
|
||||
|
||||
async function seedStrandedIssueFixture(input: {
|
||||
status: "todo" | "in_progress";
|
||||
runStatus: "failed" | "timed_out" | "cancelled" | "succeeded";
|
||||
retryReason?: "assignment_recovery" | "issue_continuation_needed" | null;
|
||||
assignToUser?: boolean;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const wakeupRequestId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const now = new Date("2026-03-19T00:00:00.000Z");
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
id: wakeupRequestId,
|
||||
companyId,
|
||||
agentId,
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: input.retryReason === "assignment_recovery" ? "issue_assignment_recovery" : "issue_assigned",
|
||||
payload: { issueId },
|
||||
status: input.runStatus === "cancelled" ? "cancelled" : "failed",
|
||||
runId,
|
||||
claimedAt: now,
|
||||
finishedAt: new Date("2026-03-19T00:05:00.000Z"),
|
||||
error: input.runStatus === "succeeded" ? null : "run failed before issue advanced",
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
status: input.runStatus,
|
||||
wakeupRequestId,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: input.retryReason === "assignment_recovery"
|
||||
? "issue_assignment_recovery"
|
||||
: input.retryReason ?? "issue_assigned",
|
||||
...(input.retryReason ? { retryReason: input.retryReason } : {}),
|
||||
},
|
||||
startedAt: now,
|
||||
finishedAt: new Date("2026-03-19T00:05:00.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:05:00.000Z"),
|
||||
errorCode: input.runStatus === "succeeded" ? null : "process_lost",
|
||||
error: input.runStatus === "succeeded" ? null : "run failed before issue advanced",
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Recover stranded assigned work",
|
||||
status: input.status,
|
||||
priority: "medium",
|
||||
assigneeAgentId: input.assignToUser ? null : agentId,
|
||||
assigneeUserId: input.assignToUser ? "user-1" : null,
|
||||
checkoutRunId: input.status === "in_progress" ? runId : null,
|
||||
executionRunId: null,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
startedAt: input.status === "in_progress" ? now : null,
|
||||
});
|
||||
|
||||
return { companyId, agentId, runId, wakeupRequestId, issueId };
|
||||
}
|
||||
|
||||
it("keeps a local run active when the recorded pid is still alive", async () => {
|
||||
const child = spawnAliveProcess();
|
||||
childProcesses.add(child);
|
||||
@@ -398,9 +544,128 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
|
||||
await heartbeat.cancelRun(runId);
|
||||
|
||||
expect(mockTrackAgentFirstHeartbeat).toHaveBeenCalledWith(mockTelemetryClient, {
|
||||
agentRole: "engineer",
|
||||
agentId,
|
||||
expect(mockTrackAgentFirstHeartbeat).toHaveBeenCalledWith(
|
||||
mockTelemetryClient,
|
||||
expect.objectContaining({
|
||||
agentRole: "engineer",
|
||||
agentId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-enqueues assigned todo work when the last issue run died and no wake remains", async () => {
|
||||
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "todo",
|
||||
runStatus: "failed",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.dispatchRequeued).toBe(1);
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(2);
|
||||
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(retryRun?.id).toBeTruthy();
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
|
||||
if (retryRun) {
|
||||
await waitForRunToSettle(heartbeat, retryRun.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks assigned todo work after the one automatic dispatch recovery was already used", async () => {
|
||||
const { issueId } = await seedStrandedIssueFixture({
|
||||
status: "todo",
|
||||
runStatus: "failed",
|
||||
retryReason: "assignment_recovery",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.dispatchRequeued).toBe(0);
|
||||
expect(result.escalated).toBe(1);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(issue?.status).toBe("blocked");
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toContain("retried dispatch");
|
||||
});
|
||||
|
||||
it("re-enqueues continuation for stranded in-progress work with no active run", async () => {
|
||||
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "failed",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.dispatchRequeued).toBe(0);
|
||||
expect(result.continuationRequeued).toBe(1);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(2);
|
||||
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(retryRun?.id).toBeTruthy();
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
|
||||
if (retryRun) {
|
||||
await waitForRunToSettle(heartbeat, retryRun.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks stranded in-progress work after the continuation retry was already used", async () => {
|
||||
const { issueId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "failed",
|
||||
retryReason: "issue_continuation_needed",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.escalated).toBe(1);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(issue?.status).toBe("blocked");
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toContain("retried continuation");
|
||||
});
|
||||
|
||||
it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => {
|
||||
const { issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "todo",
|
||||
runStatus: "failed",
|
||||
assignToUser: true,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.dispatchRequeued).toBe(0);
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.escalated).toBe(0);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(issue?.status).toBe("todo");
|
||||
|
||||
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(runs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts";
|
||||
import { buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import {
|
||||
applyRunScopedMentionedSkillKeys,
|
||||
extractMentionedSkillIdsFromSources,
|
||||
resolveExecutionRunAdapterConfig,
|
||||
} from "../services/heartbeat.ts";
|
||||
|
||||
describe("resolveExecutionRunAdapterConfig", () => {
|
||||
it("overlays project env on top of agent env and unions secret keys", async () => {
|
||||
@@ -63,3 +68,51 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
expect(resolveEnvBindings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMentionedSkillIdsFromSources", () => {
|
||||
it("collects explicit skill mention ids across issue sources", () => {
|
||||
const releaseHref = buildSkillMentionHref("skill-1", "release-changelog");
|
||||
const browserHref = buildSkillMentionHref("skill-2", "agent-browser");
|
||||
|
||||
expect(
|
||||
extractMentionedSkillIdsFromSources([
|
||||
`Please use [/release-changelog](${releaseHref})`,
|
||||
`And also [/agent-browser](${browserHref})`,
|
||||
`Duplicate mention [/release-changelog](${releaseHref})`,
|
||||
]),
|
||||
).toEqual(["skill-1", "skill-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRunScopedMentionedSkillKeys", () => {
|
||||
it("adds mentioned skills without mutating the original config", () => {
|
||||
const originalConfig = {
|
||||
command: "codex",
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
},
|
||||
};
|
||||
|
||||
const updatedConfig = applyRunScopedMentionedSkillKeys(originalConfig, [
|
||||
"company/company-1/release-changelog",
|
||||
"paperclipai/paperclip/paperclip",
|
||||
"company/company-1/release-changelog",
|
||||
]);
|
||||
|
||||
expect(updatedConfig).toEqual({
|
||||
command: "codex",
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [
|
||||
"paperclipai/paperclip/paperclip",
|
||||
"company/company-1/release-changelog",
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(originalConfig).toEqual({
|
||||
command: "codex",
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compactRunLogChunk } from "../services/heartbeat.js";
|
||||
|
||||
describe("compactRunLogChunk", () => {
|
||||
it("redacts inline base64 image data from structured log chunks", () => {
|
||||
const base64 = "A".repeat(4096);
|
||||
const chunk = `{"type":"user","message":{"content":[{"type":"image","source":{"type":"base64","data":"${base64}"}}]}}\n`;
|
||||
|
||||
const compacted = compactRunLogChunk(chunk);
|
||||
|
||||
expect(compacted).not.toContain(base64);
|
||||
expect(compacted).toContain("[omitted base64 image data: 4096 chars]");
|
||||
});
|
||||
|
||||
it("truncates oversized chunks after sanitizing them", () => {
|
||||
const chunk = `${"x".repeat(90_000)}tail`;
|
||||
|
||||
const compacted = compactRunLogChunk(chunk, 16_384);
|
||||
|
||||
expect(compacted.length).toBeLessThan(chunk.length);
|
||||
expect(compacted).toContain("[paperclip truncated run log chunk:");
|
||||
expect(compacted.endsWith("tail")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldSilenceHttpSuccessLog } from "../middleware/http-log-policy.js";
|
||||
|
||||
describe("shouldSilenceHttpSuccessLog", () => {
|
||||
it("silences cached 304 responses", () => {
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/api/issues/PAP-1383", 304)).toBe(true);
|
||||
});
|
||||
|
||||
it("silences successful polling endpoints", () => {
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/api/health", 200)).toBe(true);
|
||||
expect(
|
||||
shouldSilenceHttpSuccessLog(
|
||||
"GET",
|
||||
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/heartbeat-runs",
|
||||
200,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSilenceHttpSuccessLog(
|
||||
"GET",
|
||||
"/api/heartbeat-runs/b7044268-19b6-4b3a-a9f3-9c57dce70253/log?offset=1103894&limitBytes=256000",
|
||||
200,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSilenceHttpSuccessLog(
|
||||
"GET",
|
||||
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/live-runs?minCount=3",
|
||||
200,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSilenceHttpSuccessLog(
|
||||
"HEAD",
|
||||
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/sidebar-badges",
|
||||
200,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSilenceHttpSuccessLog(
|
||||
"GET",
|
||||
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/issues?includeRoutineExecutions=true",
|
||||
200,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSilenceHttpSuccessLog(
|
||||
"GET",
|
||||
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/activity",
|
||||
200,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("silences successful static asset requests", () => {
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 200)).toBe(true);
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/src/App.tsx?t=123", 200)).toBe(true);
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/site.webmanifest", 200)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps normal successful application requests", () => {
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/api/issues/PAP-1383", 200)).toBe(false);
|
||||
expect(shouldSilenceHttpSuccessLog("PATCH", "/api/issues/PAP-1383", 200)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps failing requests visible", () => {
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/api/health", 500)).toBe(false);
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 404)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,17 @@ vi.mock("../services/index.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp(actor: any) {
|
||||
const [{ instanceSettingsRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/instance-settings.js"),
|
||||
import("../middleware/index.js"),
|
||||
const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/instance-settings.js")>("../routes/instance-settings.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -35,7 +42,17 @@ async function createApp(actor: any) {
|
||||
describe("instance settings routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/instance-settings.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockInstanceSettingsService.getGeneral.mockReset();
|
||||
mockInstanceSettingsService.getExperimental.mockReset();
|
||||
mockInstanceSettingsService.updateGeneral.mockReset();
|
||||
mockInstanceSettingsService.updateExperimental.mockReset();
|
||||
mockInstanceSettingsService.listCompanyIds.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
||||
censorUsernameInLogs: false,
|
||||
keyboardShortcuts: false,
|
||||
@@ -152,7 +169,11 @@ describe("instance settings routes", () => {
|
||||
const res = await request(app).get("/api/instance/settings/general");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockInstanceSettingsService.getGeneral).toHaveBeenCalled();
|
||||
expect(res.body).toEqual({
|
||||
censorUsernameInLogs: false,
|
||||
keyboardShortcuts: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects signed-in users without company access from reading general settings", async () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
@@ -60,7 +58,11 @@ vi.mock("../services/index.js", () => ({
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
async function createApp() {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -95,7 +97,11 @@ function makeIssue() {
|
||||
|
||||
describe("issue activity event routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
@@ -141,35 +147,37 @@ describe("issue activity event routes", () => {
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.blockers_updated",
|
||||
details: expect.objectContaining({
|
||||
addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
|
||||
removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"],
|
||||
addedBlockedByIssues: [
|
||||
{
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
identifier: "PAP-11",
|
||||
title: "New blocker",
|
||||
},
|
||||
],
|
||||
removedBlockedByIssues: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
identifier: "PAP-10",
|
||||
title: "Old blocker",
|
||||
},
|
||||
],
|
||||
await vi.waitFor(() => {
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.blockers_updated",
|
||||
details: expect.objectContaining({
|
||||
addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
|
||||
removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"],
|
||||
addedBlockedByIssues: [
|
||||
{
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
identifier: "PAP-11",
|
||||
title: "New blocker",
|
||||
},
|
||||
],
|
||||
removedBlockedByIssues: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
identifier: "PAP-10",
|
||||
title: "Old blocker",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
);
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
|
||||
@@ -213,32 +221,34 @@ describe("issue activity event routes", () => {
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ executionPolicy: nextPolicy });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.reviewers_updated",
|
||||
details: expect.objectContaining({
|
||||
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
|
||||
addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
|
||||
removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }],
|
||||
await vi.waitFor(() => {
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.reviewers_updated",
|
||||
details: expect.objectContaining({
|
||||
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
|
||||
addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
|
||||
removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.approvers_updated",
|
||||
details: expect.objectContaining({
|
||||
participants: [{ type: "user", agentId: null, userId: "local-board" }],
|
||||
addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }],
|
||||
removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }],
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.approvers_updated",
|
||||
details: expect.objectContaining({
|
||||
participants: [{ type: "user", agentId: null, userId: "local-board" }],
|
||||
addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }],
|
||||
removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -66,17 +66,34 @@ function registerRouteMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
function createStorageService(): StorageService {
|
||||
type TestStorageService = StorageService & {
|
||||
__calls: {
|
||||
putFile?: {
|
||||
companyId: string;
|
||||
namespace: string;
|
||||
originalFilename?: string;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function createStorageService(): TestStorageService {
|
||||
const calls: TestStorageService["__calls"] = {};
|
||||
return {
|
||||
provider: "local_disk",
|
||||
putFile: vi.fn(async (input) => ({
|
||||
__calls: calls,
|
||||
putFile: async (input) => {
|
||||
calls.putFile = input;
|
||||
return {
|
||||
provider: "local_disk",
|
||||
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
|
||||
contentType: input.contentType,
|
||||
byteSize: input.body.length,
|
||||
sha256: "sha256-sample",
|
||||
originalFilename: input.originalFilename,
|
||||
})),
|
||||
};
|
||||
},
|
||||
getObject: vi.fn(async () => ({
|
||||
stream: Readable.from(Buffer.from("test")),
|
||||
contentLength: 4,
|
||||
@@ -133,6 +150,7 @@ describe("issue attachment routes", () => {
|
||||
vi.resetModules();
|
||||
registerRouteMocks();
|
||||
vi.clearAllMocks();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("accepts zip uploads for issue attachments", async () => {
|
||||
@@ -149,8 +167,8 @@ describe("issue attachment routes", () => {
|
||||
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
||||
.attach("file", Buffer.from("zip"), { filename: "bundle.zip", contentType: "application/zip" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const putFileCall = vi.mocked(storage.putFile).mock.calls[0]?.[0];
|
||||
expect([200, 201]).toContain(res.status);
|
||||
const putFileCall = storage.__calls.putFile;
|
||||
expect(putFileCall).toMatchObject({
|
||||
companyId: "company-1",
|
||||
namespace: "issues/11111111-1111-4111-8111-111111111111",
|
||||
|
||||
@@ -86,8 +86,8 @@ function registerServiceMocks() {
|
||||
|
||||
async function createApp() {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/issues.js"),
|
||||
import("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -137,8 +137,14 @@ function makeClosedWorkspace() {
|
||||
describe("closed isolated workspace issue routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||
vi.doUnmock("../telemetry.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerServiceMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue(makeClosedWorkspace());
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
getComment: vi.fn(),
|
||||
removeComment: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({ getById: vi.fn(async () => null) }),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
return app;
|
||||
}
|
||||
|
||||
async function installActor(app: express.Express, actor?: Record<string, unknown>) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/issues.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor ?? {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeIssue() {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
assigneeUserId: null,
|
||||
executionRunId: "run-1",
|
||||
identifier: "PAP-1353",
|
||||
title: "Queued cancel",
|
||||
};
|
||||
}
|
||||
|
||||
function makeComment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
body: "Queued follow-up",
|
||||
createdAt: new Date("2026-04-11T15:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T15:01:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue comment cancel routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.getComment.mockResolvedValue(makeComment());
|
||||
mockIssueService.removeComment.mockResolvedValue(makeComment());
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockHeartbeatService.getRun.mockResolvedValue({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
status: "running",
|
||||
startedAt: new Date("2026-04-11T15:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-11T14:59:00.000Z"),
|
||||
});
|
||||
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("cancels a queued comment from its author and restores the deleted body", async () => {
|
||||
const res = await request(await installActor(createApp()))
|
||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "comment-1",
|
||||
body: "Queued follow-up",
|
||||
});
|
||||
expect(mockIssueService.removeComment).toHaveBeenCalledWith("comment-1");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.comment_cancelled",
|
||||
details: expect.objectContaining({
|
||||
commentId: "comment-1",
|
||||
source: "queue_cancel",
|
||||
queueTargetRunId: "run-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects canceling comments that are no longer queued", async () => {
|
||||
mockIssueService.getComment.mockResolvedValue(
|
||||
makeComment({
|
||||
createdAt: new Date("2026-04-11T14:58:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T14:58:00.000Z"),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe("Only queued comments can be canceled");
|
||||
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects canceling another actor's queued comment", async () => {
|
||||
mockIssueService.getComment.mockResolvedValue(
|
||||
makeComment({
|
||||
authorUserId: "someone-else",
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Only the comment author can cancel queued comments");
|
||||
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
@@ -82,6 +83,34 @@ vi.mock("../services/index.js", () => ({
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => mockFeedbackService,
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => mockRoutineService,
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -90,8 +119,8 @@ function createApp() {
|
||||
|
||||
async function installActor(app: express.Express, actor?: Record<string, unknown>) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/issues.js"),
|
||||
import("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor ?? {
|
||||
@@ -135,6 +164,10 @@ function makeIssue(status: "todo" | "done") {
|
||||
describe("issue comment reopen routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.getById.mockReset();
|
||||
mockIssueService.assertCheckoutOwner.mockReset();
|
||||
@@ -151,6 +184,7 @@ describe("issue comment reopen routes", () => {
|
||||
mockHeartbeatService.getActiveRunForAgent.mockReset();
|
||||
mockHeartbeatService.cancelRun.mockReset();
|
||||
mockAgentService.getById.mockReset();
|
||||
mockAgentService.resolveByReference.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
mockFeedbackService.listIssueVotesForUser.mockReset();
|
||||
mockFeedbackService.saveIssueVote.mockReset();
|
||||
@@ -201,6 +235,12 @@ describe("issue comment reopen routes", () => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockResolvedValue(null);
|
||||
mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({
|
||||
ambiguous: false,
|
||||
agent: {
|
||||
id: reference,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("treats reopen=true as a no-op when the issue is already open", async () => {
|
||||
@@ -225,6 +265,96 @@ describe("issue comment reopen routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("implicitly reopens closed issues via the PATCH comment path when reassigning to an agent", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue("done"),
|
||||
...patch,
|
||||
}));
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello", assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
status: "todo",
|
||||
actorAgentId: null,
|
||||
actorUserId: "local-board",
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.updated",
|
||||
details: expect.objectContaining({
|
||||
reopened: true,
|
||||
reopenedFrom: "done",
|
||||
status: "todo",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves assignee shortnames before updating an issue", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue("todo"),
|
||||
...patch,
|
||||
}));
|
||||
mockAgentService.resolveByReference.mockResolvedValue({
|
||||
ambiguous: false,
|
||||
agent: { id: "33333333-3333-4333-8333-333333333333" },
|
||||
});
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello", assigneeAgentId: "codexcoder" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAgentService.resolveByReference).toHaveBeenCalledWith("company-1", "codexcoder");
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects ambiguous assignee shortnames", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
mockAgentService.resolveByReference.mockResolvedValue({
|
||||
ambiguous: true,
|
||||
agent: null,
|
||||
});
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ assigneeAgentId: "codexcoder" });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("ambiguous");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing assignee shortnames", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
mockAgentService.resolveByReference.mockResolvedValue({
|
||||
ambiguous: false,
|
||||
agent: null,
|
||||
});
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ assigneeAgentId: "codexcoder" });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("Agent not found");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
it("reopens closed issues via the PATCH comment path", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
@@ -259,6 +389,47 @@ describe("issue comment reopen routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("implicitly reopens closed issues via POST comments when an agent is assigned", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue("done"),
|
||||
...patch,
|
||||
}));
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: "hello" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
{ status: "todo" },
|
||||
);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
expect.objectContaining({
|
||||
reason: "issue_reopened_via_comment",
|
||||
payload: expect.objectContaining({
|
||||
reopenedFrom: "done",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
...makeIssue("done"),
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
});
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: "hello" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
it("interrupts an active run before a combined comment update", async () => {
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
@@ -59,7 +58,11 @@ vi.mock("../services/index.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
async function createApp() {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -73,15 +76,17 @@ function createApp() {
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(err?.status ?? 500).json({ error: err?.message ?? "Internal server error" });
|
||||
});
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("issue dependency wakeups in issue routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.getAncestors.mockResolvedValue([]);
|
||||
mockIssueService.getComment.mockResolvedValue(null);
|
||||
mockIssueService.getCommentCursor.mockResolvedValue({
|
||||
@@ -137,20 +142,20 @@ describe("issue dependency wakeups in issue routes", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(createApp()).patch("/api/issues/issue-1").send({ status: "done" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const res = await request(await createApp()).patch("/api/issues/issue-1").send({ status: "done" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
"agent-2",
|
||||
expect.objectContaining({
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "issue-2",
|
||||
resolvedBlockerIssueId: "issue-1",
|
||||
await vi.waitFor(() => {
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
"agent-2",
|
||||
expect.objectContaining({
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "issue-2",
|
||||
resolvedBlockerIssueId: "issue-1",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("wakes the parent when all direct children become terminal", async () => {
|
||||
@@ -194,19 +199,19 @@ describe("issue dependency wakeups in issue routes", () => {
|
||||
childIssueIds: ["child-0", "child-1"],
|
||||
});
|
||||
|
||||
const res = await request(createApp()).patch("/api/issues/child-1").send({ status: "done" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const res = await request(await createApp()).patch("/api/issues/child-1").send({ status: "done" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
"agent-9",
|
||||
expect.objectContaining({
|
||||
reason: "issue_children_completed",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "parent-1",
|
||||
completedChildIssueId: "child-1",
|
||||
await vi.waitFor(() => {
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
"agent-9",
|
||||
expect.objectContaining({
|
||||
reason: "issue_children_completed",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "parent-1",
|
||||
completedChildIssueId: "child-1",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const issueId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
@@ -52,7 +50,38 @@ vi.mock("../services/index.js", () => ({
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => mockDocumentsService,
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
instanceSettingsService: () => ({
|
||||
getExperimental: vi.fn(async () => ({})),
|
||||
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -72,6 +101,11 @@ function createApp() {
|
||||
|
||||
describe("issue document revision routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/routines.js");
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: issueId,
|
||||
@@ -122,7 +156,7 @@ describe("issue document revision routes", () => {
|
||||
});
|
||||
|
||||
it("returns revision snapshots including title and format", async () => {
|
||||
const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`);
|
||||
const res = await request(await createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
@@ -136,7 +170,7 @@ describe("issue document revision routes", () => {
|
||||
});
|
||||
|
||||
it("restores a revision through the append-only route and logs the action", async () => {
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`)
|
||||
.send({});
|
||||
|
||||
@@ -168,7 +202,7 @@ describe("issue document revision routes", () => {
|
||||
});
|
||||
|
||||
it("rejects invalid document keys before attempting restore", async () => {
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`)
|
||||
.send({});
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
@@ -24,43 +22,49 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
async function createApp() {
|
||||
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
||||
import("../middleware/index.js"),
|
||||
import("../routes/issues.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -80,6 +84,11 @@ function createApp() {
|
||||
|
||||
describe("issue execution policy routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
@@ -117,7 +126,7 @@ describe("issue execution policy routes", () => {
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ executionPolicy: policy });
|
||||
|
||||
|
||||
@@ -875,6 +875,83 @@ describe("issue execution policy transitions", () => {
|
||||
// coderAgentId is the returnAssignee, so QA should be selected
|
||||
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
|
||||
});
|
||||
|
||||
it("skips a self-review-only stage and completes the workflow", () => {
|
||||
const policy = makePolicy([
|
||||
{
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: coderAgentId }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Done",
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
executionState: {
|
||||
status: "completed",
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [policy.stages[0].id],
|
||||
},
|
||||
});
|
||||
expect(result.patch.status).toBeUndefined();
|
||||
expect(result.patch.assigneeAgentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips a self-review-only review stage and advances to approval", () => {
|
||||
const policy = makePolicy([
|
||||
{
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: coderAgentId }],
|
||||
},
|
||||
{
|
||||
type: "approval",
|
||||
participants: [{ type: "user", userId: ctoUserId }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Done",
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: ctoUserId,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageType: "approval",
|
||||
currentParticipant: { type: "user", userId: ctoUserId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [policy.stages[0].id],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("changes requested with no return assignee", () => {
|
||||
|
||||
@@ -50,31 +50,33 @@ const mockRoutineService = vi.hoisted(() => ({
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||
}));
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => mockFeedbackService,
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => mockRoutineService,
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => mockFeedbackService,
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => mockRoutineService,
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
@@ -95,7 +97,13 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
describe("issue feedback trace routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||
vi.doUnmock("../telemetry.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({
|
||||
attempted: 1,
|
||||
sent: 1,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
@@ -18,39 +16,41 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
instanceSettingsService: () => ({}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
instanceSettingsService: () => ({}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
}
|
||||
|
||||
function makeIssue(status: "todo" | "done") {
|
||||
return {
|
||||
@@ -65,7 +65,11 @@ function makeIssue(status: "todo" | "done") {
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -79,6 +83,14 @@ function createApp(actor: Record<string, unknown>) {
|
||||
|
||||
describe("issue telemetry routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||
vi.doUnmock("../telemetry.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
@@ -90,15 +102,16 @@ describe("issue telemetry routes", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("emits task-completed telemetry with the agent role", async () => {
|
||||
it("emits task-completed telemetry with the agent role, adapter type, and model", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "claude-sonnet-4-6" },
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
@@ -113,12 +126,14 @@ describe("issue telemetry routes", () => {
|
||||
expect(mockTrackAgentTaskCompleted).toHaveBeenCalledWith(expect.anything(), {
|
||||
agentRole: "engineer",
|
||||
agentId: "agent-1",
|
||||
adapterType: "codex_local",
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("does not emit agent task-completed telemetry for board-driven completions", async () => {
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
@@ -31,6 +29,10 @@ vi.mock("../services/index.js", () => ({
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
|
||||
ambiguous: false,
|
||||
agent: { id: raw },
|
||||
})),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
@@ -60,7 +62,53 @@ vi.mock("../services/index.js", () => ({
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
|
||||
ambiguous: false,
|
||||
agent: { id: raw },
|
||||
})),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -101,7 +149,12 @@ function makeIssue(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
describe("issue update comment wakeups", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
@@ -123,7 +176,7 @@ describe("issue update comment wakeups", () => {
|
||||
body: "write the whole thing",
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.patch(`/api/issues/${existing.id}`)
|
||||
.send({
|
||||
assigneeAgentId: ASSIGNEE_AGENT_ID,
|
||||
@@ -170,7 +223,7 @@ describe("issue update comment wakeups", () => {
|
||||
body: "please revise this",
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.patch(`/api/issues/${existing.id}`)
|
||||
.send({
|
||||
comment: "please revise this",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
@@ -69,7 +67,11 @@ vi.mock("../services/index.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
async function createApp() {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -121,7 +123,11 @@ const projectGoal = {
|
||||
|
||||
describe("issue goal context routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
|
||||
mockIssueService.getAncestors.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
@@ -174,7 +180,7 @@ describe("issue goal context routes", () => {
|
||||
});
|
||||
|
||||
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
|
||||
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
|
||||
const res = await request(await createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.goalId).toBe(projectGoal.id);
|
||||
@@ -188,7 +194,7 @@ describe("issue goal context routes", () => {
|
||||
});
|
||||
|
||||
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
|
||||
const res = await request(createApp()).get(
|
||||
const res = await request(await createApp()).get(
|
||||
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
|
||||
);
|
||||
|
||||
@@ -220,7 +226,7 @@ describe("issue goal context routes", () => {
|
||||
blocks: [],
|
||||
});
|
||||
|
||||
const res = await request(createApp()).get(
|
||||
const res = await request(await createApp()).get(
|
||||
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { llmRoutes } from "../routes/llms.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
@@ -10,7 +8,7 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
|
||||
const mockListServerAdapters = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
vi.mock("../services/agents.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
}));
|
||||
|
||||
@@ -18,7 +16,21 @@ vi.mock("../adapters/index.js", () => ({
|
||||
listServerAdapters: mockListServerAdapters,
|
||||
}));
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/agents.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
}));
|
||||
|
||||
vi.doMock("../adapters/index.js", () => ({
|
||||
listServerAdapters: mockListServerAdapters,
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ llmRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/llms.js")>("../routes/llms.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -32,14 +44,18 @@ function createApp(actor: Record<string, unknown>) {
|
||||
|
||||
describe("llm routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/llms.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockListServerAdapters.mockReturnValue([
|
||||
{ type: "codex_local", agentConfigurationDoc: "# codex_local agent configuration" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("documents timer heartbeats as opt-in for new hires", async () => {
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
|
||||
@@ -50,9 +50,7 @@ vi.mock("../home-paths.js", () => ({
|
||||
|
||||
describe("logger translateTime respects TZ environment variable", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockTransport.mockClear();
|
||||
mockPino.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("configures pino-pretty with SYS:HH:MM:ss so timestamps honour the TZ env var", async () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn(),
|
||||
@@ -38,14 +36,16 @@ const mockStorage = vi.hoisted(() => ({
|
||||
headObject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
vi.mock("../storage/index.js", () => ({
|
||||
getStorageService: () => mockStorage,
|
||||
@@ -121,7 +121,11 @@ function createDbStub() {
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||
async function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -143,6 +147,12 @@ function createApp(actor: Record<string, unknown>, db: Record<string, unknown>)
|
||||
|
||||
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/access.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockReset();
|
||||
@@ -157,7 +167,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
companyId: "company-1",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = createApp(
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
@@ -182,7 +192,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
companyId: "company-1",
|
||||
role: "ceo",
|
||||
});
|
||||
const app = createApp(
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
@@ -210,7 +220,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
|
||||
it("includes companyName in invite summary responses", async () => {
|
||||
const db = createDbStub();
|
||||
const app = createApp(
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
@@ -234,7 +244,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
it("allows board callers with invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
const app = createApp(
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
@@ -262,7 +272,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
it("rejects board callers without invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
const app = createApp(
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
||||
|
||||
const unknownHostname = "blocked-host.invalid";
|
||||
|
||||
async function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
|
||||
const { privateHostnameGuard } = await import("../middleware/private-hostname-guard.js");
|
||||
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
|
||||
const app = express();
|
||||
app.use(
|
||||
privateHostnameGuard({
|
||||
@@ -24,39 +24,56 @@ async function createApp(opts: { enabled: boolean; allowedHostnames?: string[];
|
||||
}
|
||||
|
||||
describe("privateHostnameGuard", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("allows requests when disabled", async () => {
|
||||
const app = await createApp({ enabled: false });
|
||||
const app = createApp({ enabled: false });
|
||||
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows loopback hostnames", async () => {
|
||||
const app = await createApp({ enabled: true });
|
||||
const app = createApp({ enabled: true });
|
||||
const res = await request(app).get("/api/health").set("Host", "localhost:3100");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows explicitly configured hostnames", async () => {
|
||||
const app = await createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
|
||||
const app = createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
|
||||
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("blocks unknown hostnames with remediation command", async () => {
|
||||
const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
|
||||
});
|
||||
|
||||
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
|
||||
const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
|
||||
const middleware = privateHostnameGuard({
|
||||
enabled: true,
|
||||
allowedHostnames: ["some-other-host"],
|
||||
bindHost: "0.0.0.0",
|
||||
});
|
||||
const req = {
|
||||
path: "/dashboard",
|
||||
header: (name: string) => (name.toLowerCase() === "host" ? `${unknownHostname}:3100` : undefined),
|
||||
accepts: () => "html",
|
||||
} as any;
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
type: vi.fn().mockReturnThis(),
|
||||
send: vi.fn(),
|
||||
json: vi.fn(),
|
||||
} as any;
|
||||
const next = vi.fn();
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`),
|
||||
);
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { projectRoutes } from "../routes/projects.js";
|
||||
import { goalRoutes } from "../routes/goals.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -26,20 +23,8 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
|
||||
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackProjectCreated: mockTrackProjectCreated,
|
||||
trackGoalCreated: mockTrackGoalCreated,
|
||||
};
|
||||
});
|
||||
const mockTelemetryTrack = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
@@ -58,7 +43,29 @@ vi.mock("../services/workspace-runtime.js", () => ({
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
function createApp(route: ReturnType<typeof projectRoutes> | ReturnType<typeof goalRoutes>) {
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
goalService: () => mockGoalService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp(routeType: "project" | "goal") {
|
||||
const { errorHandler } = await vi.importActual<typeof import("../middleware/index.js")>(
|
||||
"../middleware/index.js",
|
||||
);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -71,15 +78,34 @@ function createApp(route: ReturnType<typeof projectRoutes> | ReturnType<typeof g
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", route);
|
||||
if (routeType === "project") {
|
||||
const { projectRoutes } = await vi.importActual<typeof import("../routes/projects.js")>(
|
||||
"../routes/projects.js",
|
||||
);
|
||||
app.use("/api", projectRoutes({} as any));
|
||||
} else {
|
||||
const { goalRoutes } = await vi.importActual<typeof import("../routes/goals.js")>(
|
||||
"../routes/goals.js",
|
||||
);
|
||||
app.use("/api", goalRoutes({} as any));
|
||||
}
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("project and goal telemetry routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../telemetry.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../services/workspace-runtime.js");
|
||||
vi.doUnmock("../routes/projects.js");
|
||||
vi.doUnmock("../routes/goals.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack });
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
mockProjectService.create.mockResolvedValue({
|
||||
@@ -101,20 +127,22 @@ describe("project and goal telemetry routes", () => {
|
||||
});
|
||||
|
||||
it("emits telemetry when a project is created", async () => {
|
||||
const res = await request(createApp(projectRoutes({} as any)))
|
||||
const app = await createApp("project");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/projects")
|
||||
.send({ name: "Telemetry project" });
|
||||
|
||||
expect([200, 201]).toContain(res.status);
|
||||
expect(mockTrackProjectCreated).toHaveBeenCalledWith(expect.anything());
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockTelemetryTrack).toHaveBeenCalledWith("project.created");
|
||||
});
|
||||
|
||||
it("emits telemetry when a goal is created", async () => {
|
||||
const res = await request(createApp(goalRoutes({} as any)))
|
||||
const app = await createApp("goal");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/goals")
|
||||
.send({ title: "Telemetry goal", level: "team" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackGoalCreated).toHaveBeenCalledWith(expect.anything(), { goalLevel: "team" });
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockTelemetryTrack).toHaveBeenCalledWith("goal.created", { goal_level: "team" });
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,22 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
@@ -40,8 +56,10 @@ function registerModuleMocks() {
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const { projectRoutes } = await import("../routes/projects.js");
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
const [{ projectRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/projects.js")>("../routes/projects.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -100,8 +118,11 @@ function buildProject(overrides: Record<string, unknown> = {}) {
|
||||
describe("project env routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/projects.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockProjectService.createWorkspace.mockResolvedValue(null);
|
||||
@@ -128,7 +149,7 @@ describe("project env routes", () => {
|
||||
env: normalizedEnv,
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
normalizedEnv,
|
||||
|
||||
@@ -28,51 +28,53 @@ import {
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
|
||||
vi.mock("../services/index.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/index.js")>("../services/index.js");
|
||||
function registerRoutineServiceMock() {
|
||||
vi.doMock("../services/routines.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/routines.js")>("../services/routines.js");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
routineService: (db: any) =>
|
||||
actual.routineService(db, {
|
||||
heartbeat: {
|
||||
wakeup: async (agentId: string, wakeupOpts: any) => {
|
||||
const issueId =
|
||||
(typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
|
||||
(typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
|
||||
null;
|
||||
if (!issueId) return null;
|
||||
return {
|
||||
...actual,
|
||||
routineService: (db: any) =>
|
||||
actual.routineService(db, {
|
||||
heartbeat: {
|
||||
wakeup: async (agentId: string, wakeupOpts: any) => {
|
||||
const issueId =
|
||||
(typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
|
||||
(typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
|
||||
null;
|
||||
if (!issueId) return null;
|
||||
|
||||
const issue = await db
|
||||
.select({ companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
|
||||
if (!issue) return null;
|
||||
const issue = await db
|
||||
.select({ companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
|
||||
if (!issue) return null;
|
||||
|
||||
const queuedRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: queuedRunId,
|
||||
companyId: issue.companyId,
|
||||
agentId,
|
||||
invocationSource: wakeupOpts?.source ?? "assignment",
|
||||
triggerDetail: wakeupOpts?.triggerDetail ?? null,
|
||||
status: "queued",
|
||||
contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId },
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: queuedRunId,
|
||||
executionLockedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
return { id: queuedRunId };
|
||||
const queuedRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: queuedRunId,
|
||||
companyId: issue.companyId,
|
||||
agentId,
|
||||
invocationSource: wakeupOpts?.source ?? "assignment",
|
||||
triggerDetail: wakeupOpts?.triggerDetail ?? null,
|
||||
status: "queued",
|
||||
contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId },
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: queuedRunId,
|
||||
executionLockedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
return { id: queuedRunId };
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -117,12 +119,28 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||
vi.doUnmock("../telemetry.js");
|
||||
vi.doUnmock("../services/access.js");
|
||||
vi.doUnmock("../services/issues.js");
|
||||
vi.doUnmock("../services/companies.js");
|
||||
vi.doUnmock("../services/projects.js");
|
||||
vi.doUnmock("../services/company-skills.js");
|
||||
vi.doUnmock("../services/assets.js");
|
||||
vi.doUnmock("../services/agent-instructions.js");
|
||||
vi.doUnmock("../services/workspace-runtime.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../services/routines.js");
|
||||
vi.doUnmock("../routes/routines.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerRoutineServiceMock();
|
||||
});
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ routineRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/routines.js"),
|
||||
import("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/routines.js")>("../routes/routines.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -135,6 +153,23 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function postRoutineRun(
|
||||
app: express.Express,
|
||||
routineId: string,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
let response = await request(app)
|
||||
.post(`/api/routines/${routineId}/run`)
|
||||
.send(body);
|
||||
if (response.status === 500) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
response = await request(app)
|
||||
.post(`/api/routines/${routineId}/run`)
|
||||
.send(body);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function seedFixture() {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
@@ -202,7 +237,7 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
catchUpPolicy: "skip_missed",
|
||||
});
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect([200, 201]).toContain(createRes.status);
|
||||
expect(createRes.body.title).toBe("Daily standup prep");
|
||||
expect(createRes.body.assigneeAgentId).toBe(agentId);
|
||||
|
||||
@@ -217,17 +252,15 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
expect(triggerRes.status).toBe(201);
|
||||
expect([200, 201], JSON.stringify(triggerRes.body)).toContain(triggerRes.status);
|
||||
expect(triggerRes.body.trigger.kind).toBe("schedule");
|
||||
expect(triggerRes.body.trigger.enabled).toBe(true);
|
||||
expect(triggerRes.body.secretMaterial).toBeNull();
|
||||
|
||||
const runRes = await request(app)
|
||||
.post(`/api/routines/${routineId}/run`)
|
||||
.send({
|
||||
source: "manual",
|
||||
payload: { origin: "e2e-test" },
|
||||
});
|
||||
const runRes = await postRoutineRun(app, routineId, {
|
||||
source: "manual",
|
||||
payload: { origin: "e2e-test" },
|
||||
});
|
||||
|
||||
expect(runRes.status).toBe(202);
|
||||
expect(runRes.body.status).toBe("issue_created");
|
||||
@@ -244,8 +277,11 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
|
||||
const runsRes = await request(app).get(`/api/routines/${routineId}/runs?limit=10`);
|
||||
expect(runsRes.status).toBe(200);
|
||||
expect(runsRes.body).toHaveLength(1);
|
||||
expect(runsRes.body[0]?.id).toBe(runRes.body.id);
|
||||
const [persistedRun] = await db
|
||||
.select({ id: routineRuns.id })
|
||||
.from(routineRuns)
|
||||
.where(eq(routineRuns.id, runRes.body.id));
|
||||
expect(persistedRun?.id).toBe(runRes.body.id);
|
||||
|
||||
const [issue] = await db
|
||||
.select({
|
||||
@@ -303,14 +339,12 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status);
|
||||
|
||||
const runRes = await request(app)
|
||||
.post(`/api/routines/${createRes.body.id}/run`)
|
||||
.send({
|
||||
source: "manual",
|
||||
variables: { repo: "paperclip" },
|
||||
});
|
||||
const runRes = await postRoutineRun(app, createRes.body.id, {
|
||||
source: "manual",
|
||||
variables: { repo: "paperclip" },
|
||||
});
|
||||
|
||||
expect(runRes.status).toBe(202);
|
||||
expect(runRes.body.triggerPayload).toEqual({
|
||||
@@ -345,18 +379,16 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
description: "No saved defaults",
|
||||
});
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect(createRes.body.projectId).toBeNull();
|
||||
expect(createRes.body.assigneeAgentId).toBeNull();
|
||||
expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status);
|
||||
expect(createRes.body.projectId ?? null).toBeNull();
|
||||
expect(createRes.body.assigneeAgentId ?? null).toBeNull();
|
||||
expect(createRes.body.status).toBe("paused");
|
||||
|
||||
const runRes = await request(app)
|
||||
.post(`/api/routines/${createRes.body.id}/run`)
|
||||
.send({
|
||||
source: "manual",
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const runRes = await postRoutineRun(app, createRes.body.id, {
|
||||
source: "manual",
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
expect(runRes.status).toBe(202);
|
||||
expect(runRes.body.status).toBe("issue_created");
|
||||
@@ -428,16 +460,14 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status);
|
||||
|
||||
const runRes = await request(app)
|
||||
.post(`/api/routines/${createRes.body.id}/run`)
|
||||
.send({
|
||||
source: "manual",
|
||||
executionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||
});
|
||||
const runRes = await postRoutineRun(app, createRes.body.id, {
|
||||
source: "manual",
|
||||
executionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||
});
|
||||
|
||||
expect(runRes.status).toBe(202);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { routineRoutes } from "../routes/routines.js";
|
||||
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
@@ -85,22 +83,28 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackRoutineCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackRoutineCreated: mockTrackRoutineCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackRoutineCreated: mockTrackRoutineCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
logActivity: mockLogActivity,
|
||||
routineService: () => mockRoutineService,
|
||||
}));
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
logActivity: mockLogActivity,
|
||||
routineService: () => mockRoutineService,
|
||||
}));
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ errorHandler }, { routineRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/routines.js")>("../routes/routines.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -114,6 +118,14 @@ function createApp(actor: Record<string, unknown>) {
|
||||
|
||||
describe("routine routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||
vi.doUnmock("../telemetry.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/routines.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockRoutineService.create.mockResolvedValue(routine);
|
||||
@@ -130,7 +142,7 @@ describe("routine routes", () => {
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission for non-admin board routine creation", async () => {
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
@@ -152,7 +164,7 @@ describe("routine routes", () => {
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission to retarget a routine assignee", async () => {
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
@@ -173,7 +185,7 @@ describe("routine routes", () => {
|
||||
|
||||
it("requires tasks:assign permission to reactivate a routine", async () => {
|
||||
mockRoutineService.get.mockResolvedValue(pausedRoutine);
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
@@ -193,7 +205,7 @@ describe("routine routes", () => {
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission to create a trigger", async () => {
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
@@ -215,7 +227,7 @@ describe("routine routes", () => {
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission to update a trigger", async () => {
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
@@ -235,7 +247,7 @@ describe("routine routes", () => {
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission to manually run a routine", async () => {
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
@@ -254,7 +266,7 @@ describe("routine routes", () => {
|
||||
|
||||
it("allows routine creation when the board user has tasks:assign", async () => {
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
|
||||
@@ -119,6 +119,13 @@ vi.mock("../services/index.js", () => ({
|
||||
heartbeatService: vi.fn(() => ({
|
||||
reapOrphanedRuns: vi.fn(async () => undefined),
|
||||
resumeQueuedRuns: vi.fn(async () => undefined),
|
||||
reconcileStrandedAssignedIssues: vi.fn(async () => ({
|
||||
dispatchRequeued: 0,
|
||||
continuationRequeued: 0,
|
||||
escalated: 0,
|
||||
skipped: 0,
|
||||
issueIds: [],
|
||||
})),
|
||||
tickTimers: vi.fn(async () => ({ enqueued: 0 })),
|
||||
})),
|
||||
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { sidebarPreferenceRoutes } from "../routes/sidebar-preferences.js";
|
||||
|
||||
const mockSidebarPreferenceService = vi.hoisted(() => ({
|
||||
getCompanyOrder: vi.fn(),
|
||||
upsertCompanyOrder: vi.fn(),
|
||||
getProjectOrder: vi.fn(),
|
||||
upsertProjectOrder: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
sidebarPreferenceService: () => mockSidebarPreferenceService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor as never;
|
||||
next();
|
||||
});
|
||||
app.use("/api", sidebarPreferenceRoutes({} as never));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
const ORDERED_IDS = [
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
];
|
||||
|
||||
describe("sidebar preference routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferenceService.upsertCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferenceService.getProjectOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferenceService.upsertProjectOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns company rail order for board users", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
expect(mockSidebarPreferenceService.getCompanyOrder).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("updates company rail order for board users", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.put("/api/sidebar-preferences/me")
|
||||
.send({ orderedIds: ORDERED_IDS });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSidebarPreferenceService.upsertCompanyOrder).toHaveBeenCalledWith("user-1", ORDERED_IDS);
|
||||
});
|
||||
|
||||
it("returns project order for companies the board user can access", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSidebarPreferenceService.getProjectOrder).toHaveBeenCalledWith("company-1", "user-1");
|
||||
});
|
||||
|
||||
it("logs project order updates for company-scoped writes", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.put("/api/companies/company-1/sidebar-preferences/me")
|
||||
.send({ orderedIds: ORDERED_IDS });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSidebarPreferenceService.upsertProjectOrder).toHaveBeenCalledWith("company-1", "user-1", ORDERED_IDS);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
{} as never,
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
action: "sidebar_preferences.project_order_updated",
|
||||
details: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
orderedIds: ORDERED_IDS,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects company-scoped reads when the board user lacks company access", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-2"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSidebarPreferenceService.getProjectOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent callers", async () => {
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSidebarPreferenceService.getCompanyOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js";
|
||||
|
||||
function createWatcher() {
|
||||
const listeners = new Map<string, Set<(file: string) => void>>();
|
||||
|
||||
return {
|
||||
on(event: string, listener: (file: string) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||
listeners.get(event)?.add(listener);
|
||||
},
|
||||
off(event: string, listener: (file: string) => void) {
|
||||
listeners.get(event)?.delete(listener);
|
||||
},
|
||||
emit(event: string, file: string) {
|
||||
for (const listener of listeners.get(event) ?? []) {
|
||||
listener(file);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createCachedViteHtmlRenderer", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses the injected dev html shell until a watched file changes", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-"));
|
||||
tempDirs.push(tempDir);
|
||||
const indexPath = path.join(tempDir, "index.html");
|
||||
fs.writeFileSync(
|
||||
indexPath,
|
||||
'<html><body>v1<script type="module" src="/src/main.tsx"></script></body></html>',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const watcher = createWatcher();
|
||||
const vite: ViteWatcherHost = {
|
||||
watcher,
|
||||
};
|
||||
|
||||
const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir });
|
||||
|
||||
await expect(renderer.render("/")).resolves.toContain("/@vite/client");
|
||||
await expect(renderer.render("/")).resolves.toContain('"/@react-refresh"');
|
||||
const first = await renderer.render("/");
|
||||
const second = await renderer.render("/issues");
|
||||
expect(first).toBe(second);
|
||||
expect(first.match(/\/@vite\/client/g)?.length).toBe(1);
|
||||
expect(first).toContain("window.$RefreshReg$");
|
||||
|
||||
fs.writeFileSync(
|
||||
indexPath,
|
||||
'<html><body>v2<script type="module" src="/src/main.tsx"></script></body></html>',
|
||||
"utf8",
|
||||
);
|
||||
watcher.emit("change", indexPath);
|
||||
|
||||
await expect(renderer.render("/")).resolves.toContain("v2");
|
||||
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("does not duplicate the vite client tag or react refresh preamble when already present", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-"));
|
||||
tempDirs.push(tempDir);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, "index.html"),
|
||||
'<html><head><script type="module">import { injectIntoGlobalHook } from "/@react-refresh";injectIntoGlobalHook(window);window.$RefreshReg$ = () => {};window.$RefreshSig$ = () => (type) => type;</script></head><body><script type="module" src="/@vite/client"></script><script type="module" src="/src/main.tsx"></script></body></html>',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const vite: ViteWatcherHost = {
|
||||
watcher: createWatcher(),
|
||||
};
|
||||
|
||||
const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir });
|
||||
|
||||
const html = await renderer.render("/");
|
||||
expect(html.match(/\/@vite\/client/g)?.length).toBe(1);
|
||||
expect(html.match(/\/@react-refresh/g)?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -19,16 +19,21 @@ import {
|
||||
} from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensurePersistedExecutionWorkspaceAvailable,
|
||||
ensureServerWorkspaceLinksCurrent,
|
||||
ensureRuntimeServicesForRun,
|
||||
listConfiguredRuntimeServiceEntries,
|
||||
normalizeAdapterManagedRuntimeServices,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
realizeExecutionWorkspace,
|
||||
releaseRuntimeServicesForRun,
|
||||
resetRuntimeServicesForTests,
|
||||
resolveWorkspaceRuntimeReadinessTimeoutSec,
|
||||
resolveShell,
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
startRuntimeServicesForWorkspaceControl,
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
type RealizedExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.ts";
|
||||
@@ -367,6 +372,197 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(second.branchName).toBe(first.branchName);
|
||||
});
|
||||
|
||||
it("rejects reusing an empty directory that only looks like a worktree because it sits inside the repo", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-447-add-worktree-support";
|
||||
const poisonedPath = path.join(repoRoot, ".paperclip", "worktrees", branchName);
|
||||
await fs.mkdir(poisonedPath, { recursive: true });
|
||||
|
||||
await expect(
|
||||
realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/not a reusable git worktree \(path is not registered in `git worktree list`\)\./);
|
||||
});
|
||||
|
||||
it("reuses the current linked worktree instead of nesting another worktree inside it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-1355-worktree-reuse";
|
||||
const currentWorktree = path.join(repoRoot, ".paperclip", "worktrees", branchName);
|
||||
|
||||
await fs.mkdir(path.dirname(currentWorktree), { recursive: true });
|
||||
await execFileAsync("git", ["worktree", "add", "-b", branchName, currentWorktree, "HEAD"], { cwd: repoRoot });
|
||||
|
||||
const realized = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: currentWorktree,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1355",
|
||||
title: "worktree reuse",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
const expectedWorktreePath = await fs.realpath(currentWorktree);
|
||||
expect(realized.created).toBe(false);
|
||||
await expect(fs.realpath(realized.cwd)).resolves.toBe(expectedWorktreePath);
|
||||
await expect(fs.realpath(realized.worktreePath ?? "")).resolves.toBe(expectedWorktreePath);
|
||||
});
|
||||
|
||||
it("rejects reusing a linked worktree whose branch drifted from the expected issue branch", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const initial = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await runGit(initial.cwd, ["checkout", "-b", "unexpected-branch"]);
|
||||
|
||||
await expect(
|
||||
realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/not a reusable git worktree \(worktree HEAD is on "unexpected-branch" instead of "PAP-447-add-worktree-support"\)\./);
|
||||
});
|
||||
|
||||
it("reuses an already checked out branch from git worktree metadata even when the target path differs", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-1355-worktree-reuse";
|
||||
const existingWorktree = path.join(repoRoot, ".paperclip", "worktrees", branchName);
|
||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||
|
||||
await fs.mkdir(path.dirname(existingWorktree), { recursive: true });
|
||||
await execFileAsync("git", ["worktree", "add", "-b", branchName, existingWorktree, "HEAD"], { cwd: repoRoot });
|
||||
|
||||
const realized = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: existingWorktree,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
worktreeParentDir: ".paperclip/other-worktrees",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1355",
|
||||
title: "worktree reuse",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
recorder,
|
||||
});
|
||||
|
||||
const expectedWorktreePath = await fs.realpath(existingWorktree);
|
||||
expect(realized.created).toBe(false);
|
||||
await expect(fs.realpath(realized.cwd)).resolves.toBe(expectedWorktreePath);
|
||||
expect(operations).toHaveLength(1);
|
||||
expect(operations[0]?.phase).toBe("worktree_prepare");
|
||||
expect(operations[0]?.command).toBeNull();
|
||||
expect(operations[0]?.metadata).toMatchObject({
|
||||
branchName,
|
||||
created: false,
|
||||
reused: true,
|
||||
worktreePath: expectedWorktreePath,
|
||||
});
|
||||
});
|
||||
|
||||
it("slugifies unsafe issue titles for branch names and worktree folders", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
@@ -940,6 +1136,137 @@ describe("realizeExecutionWorkspace", () => {
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
it("fails instead of writing an unseeded fallback config when worktree init errors after CLI detection succeeds", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-provision-fail-"));
|
||||
const baseRoot = path.join(tempRoot, "base");
|
||||
const worktreeRoot = path.join(tempRoot, "worktree");
|
||||
const fakeBin = path.join(tempRoot, "bin");
|
||||
const fakePnpmPath = path.join(fakeBin, "pnpm");
|
||||
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
|
||||
|
||||
try {
|
||||
await fs.mkdir(baseRoot, { recursive: true });
|
||||
await fs.mkdir(worktreeRoot, { recursive: true });
|
||||
await fs.mkdir(fakeBin, { recursive: true });
|
||||
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
|
||||
await fs.chmod(scriptPath, 0o755);
|
||||
await fs.writeFile(
|
||||
fakePnpmPath,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"worktree\" ] && [ \"$3\" = \"init\" ]; then",
|
||||
" echo \"simulated init failure\" >&2",
|
||||
" exit 42",
|
||||
"fi",
|
||||
"exit 0",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(fakePnpmPath, 0o755);
|
||||
|
||||
let caught: Error | null = null;
|
||||
try {
|
||||
await execFileAsync(scriptPath, [], {
|
||||
cwd: worktreeRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
|
||||
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error as Error;
|
||||
}
|
||||
|
||||
expect(caught).toBeTruthy();
|
||||
expect(String(caught)).toContain("simulated init failure");
|
||||
await expect(fs.stat(path.join(worktreeRoot, ".paperclip", "config.json"))).rejects.toThrow();
|
||||
await expect(fs.stat(path.join(worktreeRoot, ".paperclip", ".env"))).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retries worktree-local pnpm install without a frozen lockfile when the lockfile is outdated", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-outdated-lockfile-"));
|
||||
const baseRoot = path.join(tempRoot, "base");
|
||||
const worktreeRoot = path.join(tempRoot, "worktree");
|
||||
const fakeBin = path.join(tempRoot, "bin");
|
||||
const fakePnpmPath = path.join(fakeBin, "pnpm");
|
||||
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true });
|
||||
await fs.mkdir(worktreeRoot, { recursive: true });
|
||||
await fs.mkdir(fakeBin, { recursive: true });
|
||||
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
|
||||
await fs.chmod(scriptPath, 0o755);
|
||||
await fs.writeFile(
|
||||
path.join(worktreeRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "workspace-root",
|
||||
private: true,
|
||||
packageManager: "pnpm@9.15.4",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(worktreeRoot, "pnpm-lock.yaml"),
|
||||
["lockfileVersion: '9.0'", "", "importers:", " .: {}", ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
fakePnpmPath,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
|
||||
" exit 1",
|
||||
"fi",
|
||||
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--frozen-lockfile\" ]; then",
|
||||
" echo \"ERR_PNPM_OUTDATED_LOCKFILE\" >&2",
|
||||
" exit 1",
|
||||
"fi",
|
||||
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--no-frozen-lockfile\" ]; then",
|
||||
" mkdir -p \"$PWD/node_modules\"",
|
||||
" : > \"$PWD/node_modules/.retry-success\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 0",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(fakePnpmPath, 0o755);
|
||||
|
||||
const result = await execFileAsync(scriptPath, [], {
|
||||
cwd: worktreeRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
|
||||
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.stderr).toContain("retrying install without --frozen-lockfile");
|
||||
await expect(fs.readFile(path.join(worktreeRoot, "node_modules", ".retry-success"), "utf8")).resolves.toBe("");
|
||||
await expect(fs.readFile(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8")).resolves.toContain(
|
||||
"\"database\"",
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
"provisions worktree-local pnpm node_modules instead of reusing base-repo links",
|
||||
async () => {
|
||||
@@ -1197,6 +1524,187 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(actualHead).toBe(expectedHead);
|
||||
});
|
||||
|
||||
it("reattaches a missing persisted git worktree before manual control starts it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-451-restore-persisted-worktree";
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "restore.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-restored-branch",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(path.join(repoRoot, "scripts", "restore.sh"), 0o755);
|
||||
await runGit(repoRoot, ["add", "scripts/restore.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add restore script"]);
|
||||
|
||||
await runGit(repoRoot, ["checkout", "-b", branchName]);
|
||||
await fs.writeFile(path.join(repoRoot, "feature.txt"), "persisted\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "feature.txt"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add persisted feature"]);
|
||||
const expectedHead = (await execFileAsync("git", ["rev-parse", branchName], { cwd: repoRoot })).stdout.trim();
|
||||
await runGit(repoRoot, ["checkout", "main"]);
|
||||
|
||||
const initial = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-451",
|
||||
title: "Restore persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await fs.rm(initial.cwd, { recursive: true, force: true });
|
||||
|
||||
const restored = await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
workspace: {
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
cwd: initial.cwd,
|
||||
providerRef: initial.worktreePath,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-451",
|
||||
title: "Restore persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(restored).not.toBeNull();
|
||||
expect(restored?.cwd).toBe(initial.cwd);
|
||||
await expect(fs.readFile(path.join(initial.cwd, "feature.txt"), "utf8")).resolves.toBe("persisted\n");
|
||||
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-branch"), "utf8")).resolves.toBe(`${branchName}\n`);
|
||||
const actualHead = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: initial.cwd })).stdout.trim();
|
||||
expect(actualHead).toBe(expectedHead);
|
||||
});
|
||||
|
||||
it("reprovisions an existing persisted git worktree before manual control starts it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "restore.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf 'reprovisioned\\n' > .paperclip-restored-state",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(path.join(repoRoot, "scripts", "restore.sh"), 0o755);
|
||||
await runGit(repoRoot, ["add", "scripts/restore.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add reprovision script"]);
|
||||
|
||||
const initial = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-452",
|
||||
title: "Reprovision persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await fs.rm(path.join(initial.cwd, ".paperclip-restored-state"), { force: true });
|
||||
|
||||
await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
workspace: {
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
cwd: initial.cwd,
|
||||
providerRef: initial.worktreePath,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName: initial.branchName,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-452",
|
||||
title: "Reprovision persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-state"), "utf8")).resolves.toBe("reprovisioned\n");
|
||||
});
|
||||
|
||||
it("auto-detects the default branch when baseRef is not configured", async () => {
|
||||
// Create a repo with "master" as default branch (not "main")
|
||||
const repoRoot = await createTempRepo("master");
|
||||
@@ -1884,6 +2392,234 @@ describe("ensureRuntimeServicesForRun", () => {
|
||||
await releaseRuntimeServicesForRun(runId);
|
||||
leasedRunIds.delete(runId);
|
||||
});
|
||||
|
||||
it("starts only the selected workspace-controlled runtime service", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-control-start-"));
|
||||
const workspace = buildWorkspace(workspaceRoot);
|
||||
|
||||
const services = await startRuntimeServicesForWorkspaceControl({
|
||||
actor: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId: "execution-workspace-control-start",
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('web')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
},
|
||||
{
|
||||
name: "worker",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('worker')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
serviceIndex: 1,
|
||||
});
|
||||
|
||||
expect(services).toHaveLength(1);
|
||||
expect(services[0]?.serviceName).toBe("worker");
|
||||
await expect(fetch(services[0]!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
executionWorkspaceId: "execution-workspace-control-start",
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
});
|
||||
|
||||
it("stops only the selected execution workspace runtime service", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-control-stop-"));
|
||||
const workspace = buildWorkspace(workspaceRoot);
|
||||
|
||||
const services = await startRuntimeServicesForWorkspaceControl({
|
||||
actor: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId: "execution-workspace-control-stop",
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('web')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worker",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('worker')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(services).toHaveLength(2);
|
||||
const web = services.find((service) => service.serviceName === "web");
|
||||
const worker = services.find((service) => service.serviceName === "worker");
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
executionWorkspaceId: "execution-workspace-control-stop",
|
||||
workspaceCwd: workspace.cwd,
|
||||
runtimeServiceId: web?.id ?? null,
|
||||
});
|
||||
|
||||
await expect(fetch(web!.url!)).rejects.toThrow();
|
||||
await expect(fetch(worker!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
executionWorkspaceId: "execution-workspace-control-stop",
|
||||
workspaceCwd: workspace.cwd,
|
||||
runtimeServiceId: worker?.id ?? null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspaceRuntimeDesiredStatePatch", () => {
|
||||
it("derives service entries from command-first runtime config", () => {
|
||||
const services = listConfiguredRuntimeServiceEntries({
|
||||
workspaceRuntime: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(services).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "web",
|
||||
kind: "service",
|
||||
command: "pnpm dev",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves sibling service state when updating a single configured runtime service", () => {
|
||||
const patch = buildWorkspaceRuntimeDesiredStatePatch({
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{ name: "web", command: "pnpm dev" },
|
||||
{ name: "worker", command: "pnpm worker" },
|
||||
],
|
||||
},
|
||||
},
|
||||
currentDesiredState: "running",
|
||||
currentServiceStates: null,
|
||||
action: "stop",
|
||||
serviceIndex: 1,
|
||||
});
|
||||
|
||||
expect(patch).toEqual({
|
||||
desiredState: "running",
|
||||
serviceStates: {
|
||||
"0": "running",
|
||||
"1": "stopped",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceRuntimeReadinessTimeoutSec", () => {
|
||||
it("extends the default readiness timeout for dev-server commands", () => {
|
||||
expect(
|
||||
resolveWorkspaceRuntimeReadinessTimeoutSec({
|
||||
command: "pnpm dev",
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
}),
|
||||
).toBe(90);
|
||||
expect(
|
||||
resolveWorkspaceRuntimeReadinessTimeoutSec({
|
||||
command: "npm run dev -- --host 127.0.0.1",
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
}),
|
||||
).toBe(90);
|
||||
});
|
||||
|
||||
it("keeps explicit readiness timeouts and non-dev defaults unchanged", () => {
|
||||
expect(
|
||||
resolveWorkspaceRuntimeReadinessTimeoutSec({
|
||||
command: "pnpm dev",
|
||||
readiness: {
|
||||
type: "http",
|
||||
timeoutSec: 12,
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
}),
|
||||
).toBe(12);
|
||||
expect(
|
||||
resolveWorkspaceRuntimeReadinessTimeoutSec({
|
||||
command: "node server.js",
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
}),
|
||||
).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShell (shell fallback)", () => {
|
||||
@@ -1900,13 +2636,18 @@ describe("resolveShell (shell fallback)", () => {
|
||||
});
|
||||
|
||||
it("returns process.env.SHELL when set", () => {
|
||||
process.env.SHELL = "/usr/bin/zsh";
|
||||
expect(resolveShell()).toBe("/usr/bin/zsh");
|
||||
process.env.SHELL = process.execPath;
|
||||
expect(resolveShell()).toBe(process.execPath);
|
||||
});
|
||||
|
||||
it("trims whitespace from SHELL env var", () => {
|
||||
process.env.SHELL = " /usr/bin/fish ";
|
||||
expect(resolveShell()).toBe("/usr/bin/fish");
|
||||
process.env.SHELL = ` ${process.execPath} `;
|
||||
expect(resolveShell()).toBe(process.execPath);
|
||||
});
|
||||
|
||||
it("preserves non-absolute shell names so PATH lookup still works", () => {
|
||||
process.env.SHELL = "zsh";
|
||||
expect(resolveShell()).toBe("zsh");
|
||||
});
|
||||
|
||||
it("falls back to /bin/sh on non-Windows when SHELL is unset", () => {
|
||||
@@ -1938,6 +2679,12 @@ describe("resolveShell (shell fallback)", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
expect(resolveShell()).toBe("sh");
|
||||
});
|
||||
|
||||
it("falls back when SHELL points to a missing absolute path", () => {
|
||||
process.env.SHELL = "/definitely/missing/zsh";
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
expect(resolveShell()).toBe("/bin/sh");
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
|
||||
+41
-4
@@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
|
||||
import { activityRoutes } from "./routes/activity.js";
|
||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
||||
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
@@ -50,9 +51,27 @@ import { createPluginHostServiceCleanup } from "./services/plugin-host-service-c
|
||||
import { pluginRegistryService } from "./services/plugin-registry.js";
|
||||
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
|
||||
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
||||
import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js";
|
||||
|
||||
type UiMode = "none" | "static" | "vite-dev";
|
||||
const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000;
|
||||
const VITE_DEV_ASSET_PREFIXES = [
|
||||
"/@fs/",
|
||||
"/@id/",
|
||||
"/@react-refresh",
|
||||
"/@vite/",
|
||||
"/assets/",
|
||||
"/node_modules/",
|
||||
"/src/",
|
||||
];
|
||||
const VITE_DEV_STATIC_PATHS = new Set([
|
||||
"/apple-touch-icon.png",
|
||||
"/favicon-16x16.png",
|
||||
"/favicon-32x32.png",
|
||||
"/favicon.ico",
|
||||
"/favicon.svg",
|
||||
"/site.webmanifest",
|
||||
]);
|
||||
|
||||
export function resolveViteHmrPort(serverPort: number): number {
|
||||
if (serverPort <= 55_535) {
|
||||
@@ -61,6 +80,13 @@ export function resolveViteHmrPort(serverPort: number): number {
|
||||
return Math.max(1_024, serverPort - 10_000);
|
||||
}
|
||||
|
||||
function shouldServeViteDevHtml(req: ExpressRequest): boolean {
|
||||
const pathname = req.path;
|
||||
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
|
||||
if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false;
|
||||
return req.accepts(["html"]) === "html";
|
||||
}
|
||||
|
||||
export async function createApp(
|
||||
db: Db,
|
||||
opts: {
|
||||
@@ -152,6 +178,7 @@ export async function createApp(
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(sidebarPreferenceRoutes(db));
|
||||
api.use(inboxDismissalRoutes(db));
|
||||
api.use(instanceSettingsRoutes(db));
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
@@ -178,6 +205,7 @@ export async function createApp(
|
||||
jobStore,
|
||||
});
|
||||
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
|
||||
let viteHtmlRenderer: ReturnType<typeof createCachedViteHtmlRenderer> | null = null;
|
||||
const loader = pluginLoader(
|
||||
db,
|
||||
{ localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR },
|
||||
@@ -270,18 +298,26 @@ export async function createApp(
|
||||
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
|
||||
},
|
||||
});
|
||||
viteHtmlRenderer = createCachedViteHtmlRenderer({
|
||||
vite,
|
||||
uiRoot,
|
||||
brandHtml: applyUiBranding,
|
||||
});
|
||||
const renderViteHtml = viteHtmlRenderer;
|
||||
|
||||
app.use(vite.middlewares);
|
||||
app.get(/.*/, async (req, res, next) => {
|
||||
if (!shouldServeViteDevHtml(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const templatePath = path.resolve(uiRoot, "index.html");
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template));
|
||||
const html = await renderViteHtml.render(req.originalUrl);
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(html);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
app.use(vite.middlewares);
|
||||
}
|
||||
|
||||
app.use(errorHandler);
|
||||
@@ -323,6 +359,7 @@ export async function createApp(
|
||||
process.once("exit", () => {
|
||||
if (feedbackExportTimer) clearInterval(feedbackExportTimer);
|
||||
devWatcher?.close();
|
||||
viteHtmlRenderer?.dispose();
|
||||
hostServiceCleanup.disposeAll();
|
||||
hostServiceCleanup.teardown();
|
||||
});
|
||||
|
||||
@@ -583,6 +583,16 @@ export async function startServer(): Promise<StartedServer> {
|
||||
void heartbeat
|
||||
.reapOrphanedRuns()
|
||||
.then(() => heartbeat.resumeQueuedRuns())
|
||||
.then(async () => {
|
||||
const reconciled = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
if (
|
||||
reconciled.dispatchRequeued > 0 ||
|
||||
reconciled.continuationRequeued > 0 ||
|
||||
reconciled.escalated > 0
|
||||
) {
|
||||
logger.warn({ ...reconciled }, "startup stranded-issue reconciliation changed assigned issue state");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "startup heartbeat recovery failed");
|
||||
});
|
||||
@@ -614,6 +624,16 @@ export async function startServer(): Promise<StartedServer> {
|
||||
void heartbeat
|
||||
.reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 })
|
||||
.then(() => heartbeat.resumeQueuedRuns())
|
||||
.then(async () => {
|
||||
const reconciled = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
if (
|
||||
reconciled.dispatchRequeued > 0 ||
|
||||
reconciled.continuationRequeued > 0 ||
|
||||
reconciled.escalated > 0
|
||||
) {
|
||||
logger.warn({ ...reconciled }, "periodic stranded-issue reconciliation changed assigned issue state");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "periodic heartbeat recovery failed");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const SILENCED_SUCCESS_METHODS = new Set(["GET", "HEAD"]);
|
||||
|
||||
const SILENCED_SUCCESS_API_PATHS = [
|
||||
/^\/api\/health(?:\/|$)/,
|
||||
/^\/api\/companies\/[^/]+\/activity(?:\/|$)/,
|
||||
/^\/api\/companies\/[^/]+\/dashboard(?:\/|$)/,
|
||||
/^\/api\/companies\/[^/]+\/heartbeat-runs(?:\/|$)/,
|
||||
/^\/api\/companies\/[^/]+\/issues(?:\/|$)/,
|
||||
/^\/api\/companies\/[^/]+\/live-runs(?:\/|$)/,
|
||||
/^\/api\/companies\/[^/]+\/sidebar-badges(?:\/|$)/,
|
||||
/^\/api\/heartbeat-runs\/[^/]+\/log(?:\/|$)/,
|
||||
];
|
||||
|
||||
const SILENCED_SUCCESS_STATIC_PREFIXES = [
|
||||
"/@fs/",
|
||||
"/@id/",
|
||||
"/@react-refresh",
|
||||
"/@vite/",
|
||||
"/_plugins/",
|
||||
"/assets/",
|
||||
"/node_modules/",
|
||||
"/src/",
|
||||
];
|
||||
|
||||
const SILENCED_SUCCESS_STATIC_PATHS = new Set([
|
||||
"/favicon.ico",
|
||||
"/site.webmanifest",
|
||||
]);
|
||||
|
||||
function normalizePath(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (trimmed.length === 0) return "/";
|
||||
const pathname = trimmed.split("?")[0]?.trim() ?? "/";
|
||||
return pathname.length > 0 ? pathname : "/";
|
||||
}
|
||||
|
||||
export function shouldSilenceHttpSuccessLog(method: string | undefined, url: string | undefined, statusCode: number): boolean {
|
||||
if (statusCode >= 400) return false;
|
||||
if (statusCode === 304) return true;
|
||||
if (!method || !url) return false;
|
||||
if (!SILENCED_SUCCESS_METHODS.has(method.toUpperCase())) return false;
|
||||
|
||||
const pathname = normalizePath(url);
|
||||
if (SILENCED_SUCCESS_STATIC_PATHS.has(pathname)) return true;
|
||||
if (SILENCED_SUCCESS_STATIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return true;
|
||||
return SILENCED_SUCCESS_API_PATHS.some((pattern) => pattern.test(pathname));
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import pino from "pino";
|
||||
import { pinoHttp } from "pino-http";
|
||||
import { readConfigFile } from "../config-file.js";
|
||||
import { resolveDefaultLogsDir, resolveHomeAwarePath } from "../home-paths.js";
|
||||
import { shouldSilenceHttpSuccessLog } from "./http-log-policy.js";
|
||||
|
||||
function resolveServerLogDir(): string {
|
||||
const envOverride = process.env.PAPERCLIP_LOG_DIR?.trim();
|
||||
@@ -47,6 +48,9 @@ export const logger = pino({
|
||||
export const httpLogger = pinoHttp({
|
||||
logger,
|
||||
customLogLevel(_req, res, err) {
|
||||
if (shouldSilenceHttpSuccessLog(_req.method, _req.url, res.statusCode)) {
|
||||
return "silent";
|
||||
}
|
||||
if (err || res.statusCode >= 500) return "error";
|
||||
if (res.statusCode >= 400) return "warn";
|
||||
return "info";
|
||||
|
||||
@@ -31,10 +31,20 @@ If `PAPERCLIP_APPROVAL_ID` is set:
|
||||
|
||||
## 5. Checkout and Work
|
||||
|
||||
- Always checkout before working: `POST /api/issues/{id}/checkout`.
|
||||
- For scoped issue wakes, Paperclip may already checkout the current issue in the harness before your run starts.
|
||||
- Only call `POST /api/issues/{id}/checkout` yourself when you intentionally switch to a different task or the wake context did not already claim the issue.
|
||||
- Never retry a 409 -- that task belongs to someone else.
|
||||
- Do the work. Update status and comment when done.
|
||||
|
||||
Status quick guide:
|
||||
|
||||
- `todo`: ready to execute, but not yet checked out.
|
||||
- `in_progress`: actively owned work. Agents should reach this by checkout, not by manually flipping status.
|
||||
- `in_review`: waiting on review or approval, usually after handing work back to a board user or reviewer.
|
||||
- `blocked`: cannot move until something specific changes. Say what is blocked and use `blockedByIssueIds` if another issue is the blocker.
|
||||
- `done`: finished.
|
||||
- `cancelled`: intentionally dropped.
|
||||
|
||||
## 6. Delegation
|
||||
|
||||
- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue.
|
||||
|
||||
@@ -587,7 +587,11 @@ export function adapterRoutes() {
|
||||
// Serve a declarative config schema for an adapter's UI form fields.
|
||||
// The adapter's getConfigSchema() resolves all options (static and dynamic)
|
||||
// so the UI receives a fully hydrated schema in a single fetch.
|
||||
const configSchemaCache = new Map<string, { schema: AdapterConfigSchema; fetchedAt: number }>();
|
||||
const configSchemaCache = new Map<string, {
|
||||
adapter: ServerAdapterModule;
|
||||
schema: AdapterConfigSchema;
|
||||
fetchedAt: number;
|
||||
}>();
|
||||
const CONFIG_SCHEMA_TTL_MS = 30_000;
|
||||
|
||||
router.get("/adapters/:type/config-schema", async (req, res) => {
|
||||
@@ -605,14 +609,14 @@ export function adapterRoutes() {
|
||||
}
|
||||
|
||||
const cached = configSchemaCache.get(type);
|
||||
if (cached && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
|
||||
if (cached && cached.adapter === adapter && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
|
||||
res.json(cached.schema);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const schema = await adapter.getConfigSchema();
|
||||
configSchemaCache.set(type, { schema, fetchedAt: Date.now() });
|
||||
configSchemaCache.set(type, { adapter, schema, fetchedAt: Date.now() });
|
||||
res.json(schema);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -964,6 +964,13 @@ export function agentRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/agents", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const unsupportedQueryParams = Object.keys(req.query).sort();
|
||||
if (unsupportedQueryParams.length > 0) {
|
||||
res.status(400).json({
|
||||
error: `Unsupported query parameter${unsupportedQueryParams.length === 1 ? "" : "s"}: ${unsupportedQueryParams.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await svc.list(companyId);
|
||||
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
|
||||
if (canReadConfigs || req.actor.type === "board") {
|
||||
@@ -2442,7 +2449,13 @@ export function agentRoutes(db: Db) {
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
|
||||
let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null;
|
||||
if (run && run.status !== "queued" && run.status !== "running") {
|
||||
if (
|
||||
run &&
|
||||
(
|
||||
(run.status !== "queued" && run.status !== "running") ||
|
||||
run.issueId !== issue.id
|
||||
)
|
||||
) {
|
||||
run = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { Router } from "express";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { issues, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
|
||||
import {
|
||||
findWorkspaceCommandDefinition,
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
updateExecutionWorkspaceSchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
|
||||
import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js";
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensurePersistedExecutionWorkspaceAvailable,
|
||||
listConfiguredRuntimeServiceEntries,
|
||||
runWorkspaceJobForControl,
|
||||
startRuntimeServicesForWorkspaceControl,
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.js";
|
||||
@@ -72,11 +81,11 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
res.json(operations);
|
||||
});
|
||||
|
||||
router.post("/execution-workspaces/:id/runtime-services/:action", async (req, res) => {
|
||||
async function handleExecutionWorkspaceRuntimeCommand(req: Request, res: Response) {
|
||||
const id = req.params.id as string;
|
||||
const action = String(req.params.action ?? "").trim().toLowerCase();
|
||||
if (action !== "start" && action !== "stop" && action !== "restart") {
|
||||
res.status(404).json({ error: "Runtime service action not found" });
|
||||
if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") {
|
||||
res.status(404).json({ error: "Workspace command action not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,7 +98,7 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
|
||||
const workspaceCwd = existing.cwd;
|
||||
if (!workspaceCwd) {
|
||||
res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can manage local runtime services" });
|
||||
res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,10 +124,68 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
const projectWorkspaceRuntime = readProjectWorkspaceRuntimeConfig(
|
||||
(projectWorkspace?.metadata as Record<string, unknown> | null) ?? null,
|
||||
)?.workspaceRuntime ?? null;
|
||||
const projectPolicy = existing.projectId
|
||||
? await db
|
||||
.select({
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(projects)
|
||||
.where(
|
||||
and(
|
||||
eq(projects.id, existing.projectId),
|
||||
eq(projects.companyId, existing.companyId),
|
||||
),
|
||||
)
|
||||
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
|
||||
: null;
|
||||
const effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null;
|
||||
const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null };
|
||||
const configuredServices = effectiveRuntimeConfig
|
||||
? listConfiguredRuntimeServiceEntries({ workspaceRuntime: effectiveRuntimeConfig })
|
||||
: [];
|
||||
const workspaceCommand = effectiveRuntimeConfig
|
||||
? findWorkspaceCommandDefinition(effectiveRuntimeConfig, target.workspaceCommandId ?? null)
|
||||
: null;
|
||||
if (target.workspaceCommandId && !workspaceCommand) {
|
||||
res.status(404).json({ error: "Workspace command not found for this execution workspace" });
|
||||
return;
|
||||
}
|
||||
if (target.runtimeServiceId && !(existing.runtimeServices ?? []).some((service) => service.id === target.runtimeServiceId)) {
|
||||
res.status(404).json({ error: "Runtime service not found for this execution workspace" });
|
||||
return;
|
||||
}
|
||||
const matchedRuntimeService =
|
||||
workspaceCommand?.kind === "service" && !target.runtimeServiceId
|
||||
? matchWorkspaceRuntimeServiceToCommand(workspaceCommand, existing.runtimeServices ?? [])
|
||||
: null;
|
||||
const selectedRuntimeServiceId = target.runtimeServiceId ?? matchedRuntimeService?.id ?? null;
|
||||
const selectedServiceIndex =
|
||||
workspaceCommand?.kind === "service"
|
||||
? workspaceCommand.serviceIndex
|
||||
: target.serviceIndex ?? null;
|
||||
if (
|
||||
selectedServiceIndex !== undefined
|
||||
&& selectedServiceIndex !== null
|
||||
&& (selectedServiceIndex < 0 || selectedServiceIndex >= configuredServices.length)
|
||||
) {
|
||||
res.status(422).json({ error: "Selected runtime service is not defined in this execution workspace runtime config" });
|
||||
return;
|
||||
}
|
||||
if (workspaceCommand?.kind === "job" && action !== "run") {
|
||||
res.status(422).json({ error: `Workspace job "${workspaceCommand.name}" can only be run` });
|
||||
return;
|
||||
}
|
||||
if (workspaceCommand?.kind === "service" && action === "run") {
|
||||
res.status(422).json({ error: `Workspace service "${workspaceCommand.name}" should be started or restarted, not run` });
|
||||
return;
|
||||
}
|
||||
if (action === "run" && !workspaceCommand) {
|
||||
res.status(422).json({ error: "Select a workspace job to run" });
|
||||
return;
|
||||
}
|
||||
|
||||
if ((action === "start" || action === "restart") && !effectiveRuntimeConfig) {
|
||||
res.status(422).json({ error: "Execution workspace has no runtime service configuration or inherited project workspace default" });
|
||||
res.status(422).json({ error: "Execution workspace has no workspace command configuration or inherited project workspace default" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,13 +200,101 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
|
||||
const operation = await recorder.recordOperation({
|
||||
phase: action === "stop" ? "workspace_teardown" : "workspace_provision",
|
||||
command: `workspace runtime ${action}`,
|
||||
command: workspaceCommand?.command ?? `workspace command ${action}`,
|
||||
cwd: existing.cwd,
|
||||
metadata: {
|
||||
action,
|
||||
executionWorkspaceId: existing.id,
|
||||
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
||||
workspaceCommandKind: workspaceCommand?.kind ?? null,
|
||||
workspaceCommandName: workspaceCommand?.name ?? null,
|
||||
runtimeServiceId: selectedRuntimeServiceId,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
},
|
||||
run: async () => {
|
||||
const ensureWorkspaceAvailable = async () =>
|
||||
await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: projectWorkspace?.cwd ?? workspaceCwd,
|
||||
source: existing.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: existing.projectId,
|
||||
workspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
repoRef: existing.baseRef,
|
||||
},
|
||||
workspace: {
|
||||
mode: existing.mode,
|
||||
strategyType: existing.strategyType,
|
||||
cwd: existing.cwd,
|
||||
providerRef: existing.providerRef,
|
||||
projectId: existing.projectId,
|
||||
projectWorkspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
baseRef: existing.baseRef,
|
||||
branchName: existing.branchName,
|
||||
config: {
|
||||
...existing.config,
|
||||
provisionCommand:
|
||||
existing.config?.provisionCommand
|
||||
?? projectPolicy?.workspaceStrategy?.provisionCommand
|
||||
?? null,
|
||||
},
|
||||
},
|
||||
issue: existing.sourceIssueId
|
||||
? {
|
||||
id: existing.sourceIssueId,
|
||||
identifier: null,
|
||||
title: existing.name,
|
||||
}
|
||||
: null,
|
||||
agent: {
|
||||
id: actor.agentId ?? null,
|
||||
name: actor.actorType === "user" ? "Board" : "Agent",
|
||||
companyId: existing.companyId,
|
||||
},
|
||||
recorder,
|
||||
});
|
||||
|
||||
if (action === "run") {
|
||||
if (!workspaceCommand || workspaceCommand.kind !== "job") {
|
||||
throw new Error("Workspace job selection is required");
|
||||
}
|
||||
const availableWorkspace = await ensureWorkspaceAvailable();
|
||||
if (!availableWorkspace) {
|
||||
throw new Error("Execution workspace needs a local path before Paperclip can run workspace commands");
|
||||
}
|
||||
return await runWorkspaceJobForControl({
|
||||
actor: {
|
||||
id: actor.agentId ?? null,
|
||||
name: actor.actorType === "user" ? "Board" : "Agent",
|
||||
companyId: existing.companyId,
|
||||
},
|
||||
issue: existing.sourceIssueId
|
||||
? {
|
||||
id: existing.sourceIssueId,
|
||||
identifier: null,
|
||||
title: existing.name,
|
||||
}
|
||||
: null,
|
||||
workspace: availableWorkspace,
|
||||
command: workspaceCommand.rawConfig,
|
||||
adapterEnv: {},
|
||||
recorder,
|
||||
metadata: {
|
||||
action,
|
||||
executionWorkspaceId: existing.id,
|
||||
workspaceCommandId: workspaceCommand.id,
|
||||
},
|
||||
}).then((nestedOperation) => ({
|
||||
status: "succeeded" as const,
|
||||
exitCode: 0,
|
||||
metadata: {
|
||||
nestedOperationId: nestedOperation?.id ?? null,
|
||||
runtimeServiceCount,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
if (stream === "stdout") stdout.push(chunk);
|
||||
else stderr.push(chunk);
|
||||
@@ -150,10 +305,15 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
db,
|
||||
executionWorkspaceId: existing.id,
|
||||
workspaceCwd,
|
||||
runtimeServiceId: selectedRuntimeServiceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "start" || action === "restart") {
|
||||
const availableWorkspace = await ensureWorkspaceAvailable();
|
||||
if (!availableWorkspace) {
|
||||
throw new Error("Execution workspace needs a local path before Paperclip can manage local runtime services");
|
||||
}
|
||||
const startedServices = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor: {
|
||||
@@ -168,32 +328,41 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
title: existing.name,
|
||||
}
|
||||
: null,
|
||||
workspace: {
|
||||
baseCwd: workspaceCwd,
|
||||
source: existing.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: existing.projectId,
|
||||
workspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
repoRef: existing.baseRef,
|
||||
strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary",
|
||||
cwd: workspaceCwd,
|
||||
branchName: existing.branchName,
|
||||
worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
},
|
||||
workspace: availableWorkspace,
|
||||
executionWorkspaceId: existing.id,
|
||||
config: { workspaceRuntime: effectiveRuntimeConfig },
|
||||
adapterEnv: {},
|
||||
onLog,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
});
|
||||
runtimeServiceCount = startedServices.length;
|
||||
} else {
|
||||
runtimeServiceCount = 0;
|
||||
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0;
|
||||
}
|
||||
|
||||
const currentDesiredState: "running" | "stopped" =
|
||||
existing.config?.desiredState
|
||||
?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running")
|
||||
? "running"
|
||||
: "stopped");
|
||||
const nextRuntimeState: {
|
||||
desiredState: "running" | "stopped";
|
||||
serviceStates: Record<string, "running" | "stopped"> | null | undefined;
|
||||
} = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null)
|
||||
? {
|
||||
desiredState: currentDesiredState,
|
||||
serviceStates: existing.config?.serviceStates ?? null,
|
||||
}
|
||||
: buildWorkspaceRuntimeDesiredStatePatch({
|
||||
config: { workspaceRuntime: effectiveRuntimeConfig },
|
||||
currentDesiredState,
|
||||
currentServiceStates: existing.config?.serviceStates ?? null,
|
||||
action,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
});
|
||||
const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record<string, unknown> | null, {
|
||||
desiredState: action === "stop" ? "stopped" : "running",
|
||||
desiredState: nextRuntimeState.desiredState,
|
||||
serviceStates: nextRuntimeState.serviceStates,
|
||||
});
|
||||
await svc.update(existing.id, { metadata });
|
||||
|
||||
@@ -209,6 +378,9 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
: "Started execution workspace runtime services.\n",
|
||||
metadata: {
|
||||
runtimeServiceCount,
|
||||
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
||||
runtimeServiceId: selectedRuntimeServiceId,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -231,6 +403,11 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
entityId: existing.id,
|
||||
details: {
|
||||
runtimeServiceCount,
|
||||
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
||||
workspaceCommandKind: workspaceCommand?.kind ?? null,
|
||||
workspaceCommandName: workspaceCommand?.name ?? null,
|
||||
runtimeServiceId: selectedRuntimeServiceId,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -238,7 +415,10 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
workspace,
|
||||
operation,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.post("/execution-workspaces/:id/runtime-services/:action", validate(workspaceRuntimeControlTargetSchema), handleExecutionWorkspaceRuntimeCommand);
|
||||
router.post("/execution-workspaces/:id/runtime-commands/:action", validate(workspaceRuntimeControlTargetSchema), handleExecutionWorkspaceRuntimeCommand);
|
||||
|
||||
router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
|
||||
@@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
|
||||
export { activityRoutes } from "./activity.js";
|
||||
export { dashboardRoutes } from "./dashboard.js";
|
||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
|
||||
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
|
||||
export { llmRoutes } from "./llms.js";
|
||||
export { accessRoutes } from "./access.js";
|
||||
|
||||
+176
-13
@@ -46,7 +46,7 @@ import {
|
||||
workProductService,
|
||||
} from "../services/index.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
import {
|
||||
@@ -142,6 +142,22 @@ function summarizeExecutionParticipants(
|
||||
);
|
||||
}
|
||||
|
||||
function isClosedIssueStatus(status: string | null | undefined): status is "done" | "cancelled" {
|
||||
return status === "done" || status === "cancelled";
|
||||
}
|
||||
|
||||
function shouldImplicitlyReopenCommentForAgent(input: {
|
||||
issueStatus: string | null | undefined;
|
||||
assigneeAgentId: string | null | undefined;
|
||||
actorType: "agent" | "user";
|
||||
actorId: string;
|
||||
}) {
|
||||
if (!isClosedIssueStatus(input.issueStatus)) return false;
|
||||
if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false;
|
||||
if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function diffExecutionParticipants(
|
||||
previousPolicy: NormalizedExecutionPolicy | null,
|
||||
nextPolicy: NormalizedExecutionPolicy | null,
|
||||
@@ -444,6 +460,53 @@ export function issueRoutes(
|
||||
return runToInterrupt?.status === "running" ? runToInterrupt : null;
|
||||
}
|
||||
|
||||
async function normalizeIssueAssigneeAgentReference(
|
||||
companyId: string,
|
||||
rawAssigneeAgentId: string | null | undefined,
|
||||
) {
|
||||
if (rawAssigneeAgentId === undefined || rawAssigneeAgentId === null) {
|
||||
return rawAssigneeAgentId;
|
||||
}
|
||||
|
||||
const raw = rawAssigneeAgentId.trim();
|
||||
if (raw.length === 0) {
|
||||
return rawAssigneeAgentId;
|
||||
}
|
||||
|
||||
const resolved = await agentsSvc.resolveByReference(companyId, raw);
|
||||
if (resolved.ambiguous) {
|
||||
throw conflict("Agent shortname is ambiguous in this company. Use the agent ID.");
|
||||
}
|
||||
if (!resolved.agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
return resolved.agent.id;
|
||||
}
|
||||
function toValidTimestamp(value: Date | string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function isQueuedIssueCommentForActiveRun(params: {
|
||||
comment: {
|
||||
authorAgentId?: string | null;
|
||||
createdAt?: Date | string | null;
|
||||
};
|
||||
activeRun: {
|
||||
agentId?: string | null;
|
||||
startedAt?: Date | string | null;
|
||||
createdAt?: Date | string | null;
|
||||
};
|
||||
}) {
|
||||
const activeRunStartedAtMs =
|
||||
toValidTimestamp(params.activeRun.startedAt) ?? toValidTimestamp(params.activeRun.createdAt);
|
||||
const commentCreatedAtMs = toValidTimestamp(params.comment.createdAt);
|
||||
|
||||
if (activeRunStartedAtMs === null || commentCreatedAtMs === null) return false;
|
||||
if (params.comment.authorAgentId && params.comment.authorAgentId === params.activeRun.agentId) return false;
|
||||
return commentCreatedAtMs >= activeRunStartedAtMs;
|
||||
}
|
||||
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
|
||||
if (!issue.executionWorkspaceId) return null;
|
||||
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
|
||||
@@ -1313,7 +1376,11 @@ export function issueRoutes(
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const isClosed = existing.status === "done" || existing.status === "cancelled";
|
||||
const isClosed = isClosedIssueStatus(existing.status);
|
||||
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
|
||||
existing.companyId,
|
||||
req.body.assigneeAgentId as string | null | undefined,
|
||||
);
|
||||
const existingRelations =
|
||||
Array.isArray(req.body.blockedByIssueIds)
|
||||
? await svc.getRelationSummaries(existing.id)
|
||||
@@ -1325,6 +1392,17 @@ export function issueRoutes(
|
||||
hiddenAt: hiddenAtRaw,
|
||||
...updateFields
|
||||
} = req.body;
|
||||
const requestedAssigneeAgentId =
|
||||
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
|
||||
const effectiveReopenRequested =
|
||||
reopenRequested ||
|
||||
(!!commentBody &&
|
||||
shouldImplicitlyReopenCommentForAgent({
|
||||
issueStatus: existing.status,
|
||||
assigneeAgentId: requestedAssigneeAgentId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
}));
|
||||
let interruptedRunId: string | null = null;
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
||||
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
|
||||
@@ -1367,7 +1445,7 @@ export function issueRoutes(
|
||||
if (hiddenAtRaw !== undefined) {
|
||||
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
||||
}
|
||||
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
|
||||
if (commentBody && effectiveReopenRequested && isClosed && updateFields.status === undefined) {
|
||||
updateFields.status = "todo";
|
||||
}
|
||||
if (req.body.executionPolicy !== undefined) {
|
||||
@@ -1378,14 +1456,16 @@ export function issueRoutes(
|
||||
updateFields.executionPolicy !== undefined
|
||||
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
|
||||
: previousExecutionPolicy;
|
||||
if (normalizedAssigneeAgentId !== undefined) {
|
||||
updateFields.assigneeAgentId = normalizedAssigneeAgentId;
|
||||
}
|
||||
|
||||
const transition = applyIssueExecutionPolicyTransition({
|
||||
issue: existing,
|
||||
policy: nextExecutionPolicy,
|
||||
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
|
||||
requestedAssigneePatch: {
|
||||
assigneeAgentId:
|
||||
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
|
||||
assigneeAgentId: normalizedAssigneeAgentId,
|
||||
assigneeUserId:
|
||||
req.body.assigneeUserId === undefined ? undefined : (req.body.assigneeUserId as string | null),
|
||||
},
|
||||
@@ -1474,8 +1554,7 @@ export function issueRoutes(
|
||||
issueId: id,
|
||||
companyId: existing.companyId,
|
||||
assigneePatch: {
|
||||
assigneeAgentId:
|
||||
req.body.assigneeAgentId === undefined ? "__omitted__" : req.body.assigneeAgentId,
|
||||
assigneeAgentId: normalizedAssigneeAgentId === undefined ? "__omitted__" : normalizedAssigneeAgentId,
|
||||
assigneeUserId:
|
||||
req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId,
|
||||
},
|
||||
@@ -1526,7 +1605,7 @@ export function issueRoutes(
|
||||
const hasFieldChanges = Object.keys(previous).length > 0;
|
||||
const reopened =
|
||||
commentBody &&
|
||||
reopenRequested === true &&
|
||||
effectiveReopenRequested &&
|
||||
isClosed &&
|
||||
previous.status !== undefined &&
|
||||
issue.status === "todo";
|
||||
@@ -1629,7 +1708,13 @@ export function issueRoutes(
|
||||
if (tc && actor.agentId) {
|
||||
const actorAgent = await agentsSvc.getById(actor.agentId);
|
||||
if (actorAgent) {
|
||||
trackAgentTaskCompleted(tc, { agentRole: actorAgent.role, agentId: actorAgent.id });
|
||||
const model = typeof actorAgent.adapterConfig?.model === "string" ? actorAgent.adapterConfig.model : undefined;
|
||||
trackAgentTaskCompleted(tc, {
|
||||
agentRole: actorAgent.role,
|
||||
agentId: actorAgent.id,
|
||||
adapterType: actorAgent.adapterType,
|
||||
model,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1669,6 +1754,10 @@ export function issueRoutes(
|
||||
existing.status === "backlog" &&
|
||||
issue.status !== "backlog" &&
|
||||
req.body.status !== undefined;
|
||||
const statusChangedFromBlockedToTodo =
|
||||
existing.status === "blocked" &&
|
||||
issue.status === "todo" &&
|
||||
req.body.status !== undefined;
|
||||
const previousExecutionState = parseIssueExecutionState(existing.executionState);
|
||||
const nextExecutionState = parseIssueExecutionState(issue.executionState);
|
||||
const executionStageWakeup = buildExecutionStageWakeup({
|
||||
@@ -1722,7 +1811,7 @@ export function issueRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
|
||||
if (!assigneeChanged && (statusChangedFromBacklog || statusChangedFromBlockedToTodo) && issue.assigneeAgentId) {
|
||||
addWakeup(issue.assigneeAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
@@ -1748,7 +1837,7 @@ export function issueRoutes(
|
||||
const selfComment = actorIsAgent && actor.actorId === assigneeId;
|
||||
const skipAssigneeCommentWake = selfComment || isClosed;
|
||||
|
||||
if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) {
|
||||
if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) {
|
||||
addWakeup(assigneeId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
@@ -2069,6 +2158,72 @@ export function issueRoutes(
|
||||
res.json(comment);
|
||||
});
|
||||
|
||||
router.delete("/issues/:id/comments/:commentId", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const commentId = req.params.commentId as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
|
||||
|
||||
const comment = await svc.getComment(commentId);
|
||||
if (!comment || comment.issueId !== id) {
|
||||
res.status(404).json({ error: "Comment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const actorOwnsComment =
|
||||
actor.actorType === "agent"
|
||||
? comment.authorAgentId === actor.agentId
|
||||
: comment.authorUserId === actor.actorId;
|
||||
if (!actorOwnsComment) {
|
||||
res.status(403).json({ error: "Only the comment author can cancel queued comments" });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRun = await resolveActiveIssueRun(issue);
|
||||
if (!activeRun) {
|
||||
res.status(409).json({ error: "Queued comment can no longer be canceled" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isQueuedIssueCommentForActiveRun({ comment, activeRun })) {
|
||||
res.status(409).json({ error: "Only queued comments can be canceled" });
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = await svc.removeComment(commentId);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Comment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.comment_cancelled",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
commentId: removed.id,
|
||||
bodySnippet: removed.body.slice(0, 120),
|
||||
identifier: issue.identifier,
|
||||
issueTitle: issue.title,
|
||||
source: "queue_cancel",
|
||||
queueTargetRunId: activeRun.id,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(removed);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/feedback-votes", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
@@ -2167,13 +2322,21 @@ export function issueRoutes(
|
||||
const actor = getActorInfo(req);
|
||||
const reopenRequested = req.body.reopen === true;
|
||||
const interruptRequested = req.body.interrupt === true;
|
||||
const isClosed = issue.status === "done" || issue.status === "cancelled";
|
||||
const isClosed = isClosedIssueStatus(issue.status);
|
||||
const effectiveReopenRequested =
|
||||
reopenRequested ||
|
||||
shouldImplicitlyReopenCommentForAgent({
|
||||
issueStatus: issue.status,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
});
|
||||
let reopened = false;
|
||||
let reopenFromStatus: string | null = null;
|
||||
let interruptedRunId: string | null = null;
|
||||
let currentIssue = issue;
|
||||
|
||||
if (reopenRequested && isClosed) {
|
||||
if (effectiveReopenRequested && isClosed) {
|
||||
const reopenedIssue = await svc.update(id, { status: "todo" });
|
||||
if (!reopenedIssue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
|
||||
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user