Merge pull request #1654 from paperclipai/pr/pap-795-agent-runtime
fix(runtime): improve agent recovery and heartbeat operations
This commit is contained in:
commit
f2637e6972
28 changed files with 1291 additions and 64 deletions
|
|
@ -40,6 +40,12 @@ pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
|||
|
||||
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
||||
## Instructions Resolution
|
||||
|
||||
If `instructionsFilePath` is configured, Paperclip reads that file and prepends it to the stdin prompt sent to `codex exec` on every run.
|
||||
|
||||
This is separate from any workspace-level instruction discovery that Codex itself performs in the run `cwd`. Paperclip does not disable Codex-native repo instruction files, so a repo-local `AGENTS.md` may still be loaded by Codex in addition to the Paperclip-managed agent instructions.
|
||||
|
||||
## Environment Test
|
||||
|
||||
The environment test checks:
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ Operational fields:
|
|||
|
||||
Notes:
|
||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
||||
- If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run.
|
||||
- Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath.
|
||||
- Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home.
|
||||
- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex).
|
||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
||||
|
|
|
|||
|
|
@ -427,16 +427,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
);
|
||||
}
|
||||
}
|
||||
const repoAgentsNote =
|
||||
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.";
|
||||
const commandNotes = (() => {
|
||||
if (!instructionsFilePath) return [] as string[];
|
||||
if (!instructionsFilePath) {
|
||||
return [repoAgentsNote];
|
||||
}
|
||||
if (instructionsPrefix.length > 0) {
|
||||
return [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||
repoAgentsNote,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
repoAgentsNote,
|
||||
];
|
||||
})();
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export const updateAgentSchema = createAgentSchema
|
|||
.partial()
|
||||
.extend({
|
||||
permissions: z.never().optional(),
|
||||
replaceAdapterConfig: z.boolean().optional(),
|
||||
status: z.enum(AGENT_STATUSES).optional(),
|
||||
spentMonthlyCents: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
|
|
|||
38
scripts/dev-runner-paths.mjs
Normal file
38
scripts/dev-runner-paths.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
const testDirectoryNames = new Set([
|
||||
"__tests__",
|
||||
"_tests",
|
||||
"test",
|
||||
"tests",
|
||||
]);
|
||||
|
||||
const ignoredTestConfigBasenames = new Set([
|
||||
"jest.config.cjs",
|
||||
"jest.config.js",
|
||||
"jest.config.mjs",
|
||||
"jest.config.ts",
|
||||
"playwright.config.ts",
|
||||
"vitest.config.ts",
|
||||
]);
|
||||
|
||||
export function shouldTrackDevServerPath(relativePath) {
|
||||
const normalizedPath = String(relativePath).replaceAll("\\", "/").replace(/^\.\/+/, "");
|
||||
if (normalizedPath.length === 0) return false;
|
||||
|
||||
const segments = normalizedPath.split("/");
|
||||
const basename = segments.at(-1) ?? normalizedPath;
|
||||
|
||||
if (segments.includes(".paperclip")) {
|
||||
return false;
|
||||
}
|
||||
if (ignoredTestConfigBasenames.has(basename)) {
|
||||
return false;
|
||||
}
|
||||
if (segments.some((segment) => testDirectoryNames.has(segment))) {
|
||||
return false;
|
||||
}
|
||||
if (/\.(test|spec)\.[^/]+$/i.test(basename)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import path from "node:path";
|
|||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||
|
||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||
const cliArgs = process.argv.slice(3);
|
||||
|
|
@ -16,7 +17,6 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
|
|||
const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
|
||||
|
||||
const watchedDirectories = [
|
||||
".paperclip",
|
||||
"cli",
|
||||
"scripts",
|
||||
"server",
|
||||
|
|
@ -165,6 +165,7 @@ function readSignature(absolutePath) {
|
|||
function addFileToSnapshot(snapshot, absolutePath) {
|
||||
const relativePath = toRelativePath(absolutePath);
|
||||
if (ignoredRelativePaths.has(relativePath)) return;
|
||||
if (!shouldTrackDevServerPath(relativePath)) return;
|
||||
snapshot.set(relativePath, readSignature(absolutePath));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -197,4 +197,122 @@ describe("agent instructions bundle routes", () => {
|
|||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves managed instructions config when switching adapters", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...makeAgent(),
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
model: "claude-sonnet-4",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: expect.objectContaining({
|
||||
model: "claude-sonnet-4",
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges same-adapter config patches so instructions metadata is not dropped", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...makeAgent(),
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
adapterConfig: {
|
||||
command: "codex --profile engineer",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
command: "codex --profile engineer",
|
||||
model: "gpt-5.4",
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces adapter config when replaceAdapterConfig is true", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...makeAgent(),
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
replaceAdapterConfig: true,
|
||||
adapterConfig: {
|
||||
command: "codex --profile engineer",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
command: "codex --profile engineer",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(res.body.adapterConfig).toMatchObject({
|
||||
command: "codex --profile engineer",
|
||||
});
|
||||
expect(res.body.adapterConfig.instructionsBundleMode).toBeUndefined();
|
||||
expect(res.body.adapterConfig.instructionsRootPath).toBeUndefined();
|
||||
expect(res.body.adapterConfig.instructionsEntryFile).toBeUndefined();
|
||||
expect(res.body.adapterConfig.instructionsFilePath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -161,4 +161,201 @@ describe("agent instructions service", () => {
|
|||
"docs/TOOLS.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("recovers a managed bundle from disk when bundle config metadata is missing", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-recover-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
await fs.mkdir(managedRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Recovered Agent\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({});
|
||||
|
||||
const bundle = await svc.getBundle(agent);
|
||||
const exported = await svc.exportFiles(agent);
|
||||
|
||||
expect(bundle.mode).toBe("managed");
|
||||
expect(bundle.rootPath).toBe(managedRoot);
|
||||
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
|
||||
expect(exported.files).toEqual({ "AGENTS.md": "# Recovered Agent\n" });
|
||||
});
|
||||
|
||||
it("prefers the managed bundle on disk when managed metadata points at a stale root", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-stale-managed-");
|
||||
const staleRoot = await makeTempDir("paperclip-agent-instructions-stale-root-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
cleanupDirs.add(staleRoot);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
await fs.mkdir(managedRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: staleRoot,
|
||||
instructionsEntryFile: "docs/MISSING.md",
|
||||
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
|
||||
});
|
||||
|
||||
const bundle = await svc.getBundle(agent);
|
||||
const exported = await svc.exportFiles(agent);
|
||||
|
||||
expect(bundle.mode).toBe("managed");
|
||||
expect(bundle.rootPath).toBe(managedRoot);
|
||||
expect(bundle.entryFile).toBe("AGENTS.md");
|
||||
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
|
||||
expect(bundle.warnings).toEqual([
|
||||
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
|
||||
"Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
|
||||
]);
|
||||
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
|
||||
});
|
||||
|
||||
it("heals stale managed metadata when writing bundle files", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-write-");
|
||||
const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-write-stale-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
cleanupDirs.add(staleRoot);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: staleRoot,
|
||||
instructionsEntryFile: "docs/MISSING.md",
|
||||
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
|
||||
});
|
||||
|
||||
const result = await svc.writeFile(agent, "docs/TOOLS.md", "## Tools\n");
|
||||
|
||||
expect(result.adapterConfig).toMatchObject({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: managedRoot,
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
|
||||
});
|
||||
await expect(fs.readFile(path.join(managedRoot, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n");
|
||||
});
|
||||
|
||||
it("heals stale managed metadata when deleting bundle files", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-delete-");
|
||||
const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-delete-stale-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
cleanupDirs.add(staleRoot);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
|
||||
await fs.writeFile(path.join(managedRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: staleRoot,
|
||||
instructionsEntryFile: "docs/MISSING.md",
|
||||
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
|
||||
});
|
||||
|
||||
const result = await svc.deleteFile(agent, "docs/TOOLS.md");
|
||||
|
||||
expect(result.adapterConfig).toMatchObject({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: managedRoot,
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
|
||||
});
|
||||
await expect(fs.stat(path.join(managedRoot, "docs", "TOOLS.md"))).rejects.toThrow();
|
||||
expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
|
||||
});
|
||||
|
||||
it("recovers the managed bundle when stale root metadata is present but mode is missing", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-partial-managed-");
|
||||
const staleRoot = await makeTempDir("paperclip-agent-instructions-partial-root-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
cleanupDirs.add(staleRoot);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
await fs.mkdir(managedRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsRootPath: staleRoot,
|
||||
instructionsEntryFile: "docs/MISSING.md",
|
||||
});
|
||||
|
||||
const bundle = await svc.getBundle(agent);
|
||||
const exported = await svc.exportFiles(agent);
|
||||
|
||||
expect(bundle.mode).toBe("managed");
|
||||
expect(bundle.rootPath).toBe(managedRoot);
|
||||
expect(bundle.entryFile).toBe("AGENTS.md");
|
||||
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
|
||||
expect(bundle.warnings).toEqual([
|
||||
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
|
||||
"Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
|
||||
]);
|
||||
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -139,6 +139,62 @@ describe("codex execute", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("emits a command note that Codex auto-applies repo-scoped AGENTS.md files", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-notes-"));
|
||||
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;
|
||||
|
||||
let commandNotes: string[] = [];
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-notes",
|
||||
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: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
commandNotes = Array.isArray(meta.commandNotes) ? meta.commandNotes : [];
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(commandNotes).toContain(
|
||||
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.",
|
||||
);
|
||||
} 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");
|
||||
|
|
|
|||
25
server/src/__tests__/dev-runner-paths.test.ts
Normal file
25
server/src/__tests__/dev-runner-paths.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldTrackDevServerPath } from "../../../scripts/dev-runner-paths.mjs";
|
||||
|
||||
describe("shouldTrackDevServerPath", () => {
|
||||
it("ignores repo-local Paperclip state and common test file paths", () => {
|
||||
expect(
|
||||
shouldTrackDevServerPath(
|
||||
".paperclip/worktrees/PAP-712-for-project-configuration-get-rid-of-the-overview-tab-for-now/.agents/skills/paperclip",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(shouldTrackDevServerPath("server/src/__tests__/health.test.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.test.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.spec.tsx")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/_tests/helpers.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/tests/helpers.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/test/helpers.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("vitest.config.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps runtime paths restart-relevant", () => {
|
||||
expect(shouldTrackDevServerPath("server/src/routes/health.ts")).toBe(true);
|
||||
expect(shouldTrackDevServerPath("packages/shared/src/index.ts")).toBe(true);
|
||||
expect(shouldTrackDevServerPath("server/src/testing/runtime.ts")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import {
|
|||
buildExecutionWorkspaceAdapterConfig,
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceMode,
|
||||
|
|
@ -142,6 +143,16 @@ describe("execution workspace policy helpers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("maps persisted execution workspace modes back to issue settings", () => {
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("shared_workspace")).toBe("shared_workspace");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("adapter_managed")).toBe("agent_default");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("cloud_sandbox")).toBe("agent_default");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace(null)).toBe("agent_default");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace(undefined)).toBe("agent_default");
|
||||
});
|
||||
|
||||
it("disables project execution workspace policy when the instance flag is off", () => {
|
||||
expect(
|
||||
gateProjectExecutionWorkspacePolicy(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { agents } from "@paperclipai/db";
|
||||
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import {
|
||||
buildExplicitResumeSessionOverride,
|
||||
formatRuntimeWorkspaceWarningLog,
|
||||
prioritizeProjectWorkspaceCandidatesForRun,
|
||||
parseSessionCompactionPolicy,
|
||||
|
|
@ -182,6 +184,57 @@ describe("shouldResetTaskSessionForWake", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("buildExplicitResumeSessionOverride", () => {
|
||||
it("reuses saved task session params when they belong to the selected failed run", () => {
|
||||
const result = buildExplicitResumeSessionOverride({
|
||||
resumeFromRunId: "run-1",
|
||||
resumeRunSessionIdBefore: "session-before",
|
||||
resumeRunSessionIdAfter: "session-after",
|
||||
taskSession: {
|
||||
sessionParamsJson: {
|
||||
sessionId: "session-after",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
sessionDisplayId: "session-after",
|
||||
lastRunId: "run-1",
|
||||
},
|
||||
sessionCodec: codexSessionCodec,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sessionDisplayId: "session-after",
|
||||
sessionParams: {
|
||||
sessionId: "session-after",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the selected run session id when no matching task session params are available", () => {
|
||||
const result = buildExplicitResumeSessionOverride({
|
||||
resumeFromRunId: "run-1",
|
||||
resumeRunSessionIdBefore: "session-before",
|
||||
resumeRunSessionIdAfter: "session-after",
|
||||
taskSession: {
|
||||
sessionParamsJson: {
|
||||
sessionId: "other-session",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
sessionDisplayId: "other-session",
|
||||
lastRunId: "run-2",
|
||||
},
|
||||
sessionCodec: codexSessionCodec,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sessionDisplayId: "session-after",
|
||||
sessionParams: {
|
||||
sessionId: "session-after",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRuntimeWorkspaceWarningLog", () => {
|
||||
it("emits informational workspace warnings on stdout", () => {
|
||||
expect(formatRuntimeWorkspaceWarningLog("Using fallback workspace")).toEqual({
|
||||
|
|
|
|||
284
server/src/__tests__/issues-service.test.ts
Normal file
284
server/src/__tests__/issues-service.test.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
applyPendingMigrations,
|
||||
companies,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, dataDir, instance };
|
||||
}
|
||||
|
||||
describe("issueService.list participantAgentId", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
svc = issueService(db);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns issues an agent participated in across the supported signals", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const otherAgentId = 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: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: otherAgentId,
|
||||
companyId,
|
||||
name: "OtherAgent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const assignedIssueId = randomUUID();
|
||||
const createdIssueId = randomUUID();
|
||||
const commentedIssueId = randomUUID();
|
||||
const activityIssueId = randomUUID();
|
||||
const excludedIssueId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: assignedIssueId,
|
||||
companyId,
|
||||
title: "Assigned issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
createdByAgentId: otherAgentId,
|
||||
},
|
||||
{
|
||||
id: createdIssueId,
|
||||
companyId,
|
||||
title: "Created issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: commentedIssueId,
|
||||
companyId,
|
||||
title: "Commented issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: otherAgentId,
|
||||
},
|
||||
{
|
||||
id: activityIssueId,
|
||||
companyId,
|
||||
title: "Activity issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: otherAgentId,
|
||||
},
|
||||
{
|
||||
id: excludedIssueId,
|
||||
companyId,
|
||||
title: "Excluded issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: otherAgentId,
|
||||
assigneeAgentId: otherAgentId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: commentedIssueId,
|
||||
authorAgentId: agentId,
|
||||
body: "Investigating this issue.",
|
||||
});
|
||||
|
||||
await db.insert(activityLog).values({
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: agentId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: activityIssueId,
|
||||
agentId,
|
||||
details: { changed: true },
|
||||
});
|
||||
|
||||
const result = await svc.list(companyId, { participantAgentId: agentId });
|
||||
const resultIds = new Set(result.map((issue) => issue.id));
|
||||
|
||||
expect(resultIds).toEqual(new Set([
|
||||
assignedIssueId,
|
||||
createdIssueId,
|
||||
commentedIssueId,
|
||||
activityIssueId,
|
||||
]));
|
||||
expect(resultIds.has(excludedIssueId)).toBe(false);
|
||||
});
|
||||
|
||||
it("combines participation filtering with search", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
await 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: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const matchedIssueId = randomUUID();
|
||||
const otherIssueId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: matchedIssueId,
|
||||
companyId,
|
||||
title: "Invoice reconciliation",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: otherIssueId,
|
||||
companyId,
|
||||
title: "Weekly planning",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
participantAgentId: agentId,
|
||||
q: "invoice",
|
||||
});
|
||||
|
||||
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
||||
});
|
||||
});
|
||||
|
|
@ -43,7 +43,7 @@ import {
|
|||
workspaceOperationService,
|
||||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
|
|
@ -73,6 +73,13 @@ export function agentRoutes(db: Db) {
|
|||
};
|
||||
const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS));
|
||||
const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
|
||||
const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [
|
||||
"instructionsBundleMode",
|
||||
"instructionsRootPath",
|
||||
"instructionsEntryFile",
|
||||
"instructionsFilePath",
|
||||
"agentsMdPath",
|
||||
] as const;
|
||||
|
||||
const router = Router();
|
||||
const svc = agentService(db);
|
||||
|
|
@ -303,6 +310,24 @@ export function agentRoutes(db: Db) {
|
|||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function preserveInstructionsBundleConfig(
|
||||
existingAdapterConfig: Record<string, unknown>,
|
||||
nextAdapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
const nextKeys = new Set(Object.keys(nextAdapterConfig));
|
||||
if (KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => nextKeys.has(key))) {
|
||||
return nextAdapterConfig;
|
||||
}
|
||||
|
||||
const merged = { ...nextAdapterConfig };
|
||||
for (const key of KNOWN_INSTRUCTIONS_BUNDLE_KEYS) {
|
||||
if (merged[key] === undefined && existingAdapterConfig[key] !== undefined) {
|
||||
merged[key] = existingAdapterConfig[key];
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function parseBooleanLike(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
|
|
@ -830,17 +855,7 @@ export function agentRoutes(db: Db) {
|
|||
});
|
||||
|
||||
router.get("/instance/scheduler-heartbeats", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const accessConditions = [];
|
||||
if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) {
|
||||
const allowedCompanyIds = req.actor.companyIds ?? [];
|
||||
if (allowedCompanyIds.length === 0) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds));
|
||||
}
|
||||
assertInstanceAdmin(req);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
|
|
@ -858,7 +873,6 @@ export function agentRoutes(db: Db) {
|
|||
})
|
||||
.from(agentsTable)
|
||||
.innerJoin(companies, eq(agentsTable.companyId, companies.id))
|
||||
.where(accessConditions.length > 0 ? and(...accessConditions) : undefined)
|
||||
.orderBy(companies.name, agentsTable.name);
|
||||
|
||||
const items: InstanceSchedulerHeartbeatAgent[] = rows
|
||||
|
|
@ -887,7 +901,6 @@ export function agentRoutes(db: Db) {
|
|||
};
|
||||
})
|
||||
.filter((item) =>
|
||||
item.intervalSec > 0 &&
|
||||
item.status !== "paused" &&
|
||||
item.status !== "terminated" &&
|
||||
item.status !== "pending_approval",
|
||||
|
|
@ -1689,6 +1702,8 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
|
||||
const patchData = { ...(req.body as Record<string, unknown>) };
|
||||
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
|
||||
delete patchData.replaceAdapterConfig;
|
||||
if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
|
||||
const adapterConfig = asRecord(patchData.adapterConfig);
|
||||
if (!adapterConfig) {
|
||||
|
|
@ -1710,9 +1725,31 @@ export function agentRoutes(db: Db) {
|
|||
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
||||
if (touchesAdapterConfiguration) {
|
||||
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
|
||||
const changingAdapterType =
|
||||
typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType;
|
||||
const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||
? (asRecord(patchData.adapterConfig) ?? {})
|
||||
: (asRecord(existing.adapterConfig) ?? {});
|
||||
: null;
|
||||
if (
|
||||
requestedAdapterConfig
|
||||
&& replaceAdapterConfig
|
||||
&& KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) =>
|
||||
existingAdapterConfig[key] !== undefined && requestedAdapterConfig[key] === undefined,
|
||||
)
|
||||
) {
|
||||
await assertCanManageInstructionsPath(req, existing);
|
||||
}
|
||||
let rawEffectiveAdapterConfig = requestedAdapterConfig ?? existingAdapterConfig;
|
||||
if (requestedAdapterConfig && !changingAdapterType && !replaceAdapterConfig) {
|
||||
rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig };
|
||||
}
|
||||
if (changingAdapterType) {
|
||||
rawEffectiveAdapterConfig = preserveInstructionsBundleConfig(
|
||||
existingAdapterConfig,
|
||||
rawEffectiveAdapterConfig,
|
||||
);
|
||||
}
|
||||
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
requestedAdapterType,
|
||||
rawEffectiveAdapterConfig,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ export function assertBoard(req: Request) {
|
|||
}
|
||||
}
|
||||
|
||||
export function assertInstanceAdmin(req: Request) {
|
||||
assertBoard(req);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
|
||||
return;
|
||||
}
|
||||
throw forbidden("Instance admin access required");
|
||||
}
|
||||
|
||||
export function assertCompanyAccess(req: Request, companyId: string) {
|
||||
if (req.actor.type === "none") {
|
||||
throw unauthorized();
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
const result = await svc.list(companyId, {
|
||||
status: req.query.status as string | undefined,
|
||||
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
||||
participantAgentId: req.query.participantAgentId as string | undefined,
|
||||
assigneeUserId,
|
||||
touchedByUserId,
|
||||
unreadForUserId,
|
||||
|
|
|
|||
|
|
@ -272,6 +272,62 @@ function deriveBundleState(agent: AgentLike): BundleState {
|
|||
};
|
||||
}
|
||||
|
||||
async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise<BundleState> {
|
||||
const managedRootPath = resolveManagedInstructionsRoot(agent);
|
||||
const stat = await statIfExists(managedRootPath);
|
||||
if (!stat?.isDirectory()) return state;
|
||||
|
||||
const files = await listFilesRecursive(managedRootPath);
|
||||
if (files.length === 0) return state;
|
||||
|
||||
const recoveredEntryFile = files.includes(state.entryFile)
|
||||
? state.entryFile
|
||||
: files.includes(ENTRY_FILE_DEFAULT)
|
||||
? ENTRY_FILE_DEFAULT
|
||||
: files[0]!;
|
||||
|
||||
if (!state.rootPath) {
|
||||
return {
|
||||
...state,
|
||||
mode: "managed",
|
||||
rootPath: managedRootPath,
|
||||
entryFile: recoveredEntryFile,
|
||||
resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile),
|
||||
};
|
||||
}
|
||||
|
||||
if (state.mode === "external") return state;
|
||||
|
||||
const resolvedConfiguredRoot = path.resolve(state.rootPath);
|
||||
const configuredRootMatchesManaged = resolvedConfiguredRoot === managedRootPath;
|
||||
const hasEntryMismatch = recoveredEntryFile !== state.entryFile;
|
||||
|
||||
if (configuredRootMatchesManaged && !hasEntryMismatch) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const warnings = [...state.warnings];
|
||||
if (!configuredRootMatchesManaged) {
|
||||
warnings.push(
|
||||
`Recovered managed instructions from disk at ${managedRootPath}; ignoring stale configured root ${state.rootPath}.`,
|
||||
);
|
||||
}
|
||||
if (hasEntryMismatch) {
|
||||
warnings.push(
|
||||
`Recovered managed instructions entry file from disk as ${recoveredEntryFile}; previous entry ${state.entryFile} was missing.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
mode: "managed",
|
||||
rootPath: managedRootPath,
|
||||
entryFile: recoveredEntryFile,
|
||||
resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle {
|
||||
const nextFiles = [...files];
|
||||
if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) {
|
||||
|
|
@ -327,6 +383,36 @@ function applyBundleConfig(
|
|||
return next;
|
||||
}
|
||||
|
||||
function buildPersistedBundleConfig(
|
||||
derived: BundleState,
|
||||
current: BundleState,
|
||||
options?: { clearLegacyPromptTemplate?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const currentRootPath = current.rootPath ? path.resolve(current.rootPath) : null;
|
||||
const derivedRootPath = derived.rootPath ? path.resolve(derived.rootPath) : null;
|
||||
const configMatchesRecoveredState =
|
||||
derived.mode === current.mode
|
||||
&& derivedRootPath !== null
|
||||
&& currentRootPath !== null
|
||||
&& derivedRootPath === currentRootPath
|
||||
&& derived.entryFile === current.entryFile;
|
||||
|
||||
if (configMatchesRecoveredState && !options?.clearLegacyPromptTemplate) {
|
||||
return current.config;
|
||||
}
|
||||
|
||||
if (!current.rootPath || !current.mode) {
|
||||
return current.config;
|
||||
}
|
||||
|
||||
return applyBundleConfig(current.config, {
|
||||
mode: current.mode,
|
||||
rootPath: current.rootPath,
|
||||
entryFile: current.entryFile,
|
||||
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeBundleFiles(
|
||||
rootPath: string,
|
||||
files: Record<string, string>,
|
||||
|
|
@ -366,7 +452,7 @@ export function syncInstructionsBundleConfigFromFilePath(
|
|||
|
||||
export function agentInstructionsService() {
|
||||
async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> {
|
||||
const state = deriveBundleState(agent);
|
||||
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
|
||||
if (!state.rootPath) return toBundle(agent, state, []);
|
||||
const stat = await statIfExists(state.rootPath);
|
||||
if (!stat?.isDirectory()) {
|
||||
|
|
@ -381,7 +467,7 @@ export function agentInstructionsService() {
|
|||
}
|
||||
|
||||
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
|
||||
const state = deriveBundleState(agent);
|
||||
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
|
||||
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
|
||||
const content = asString(state.config[PROMPT_KEY]);
|
||||
if (content === null) throw notFound("Instructions file not found");
|
||||
|
|
@ -422,9 +508,14 @@ export function agentInstructionsService() {
|
|||
agent: AgentLike,
|
||||
options?: { clearLegacyPromptTemplate?: boolean },
|
||||
): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> {
|
||||
const current = deriveBundleState(agent);
|
||||
const derived = deriveBundleState(agent);
|
||||
const current = await recoverManagedBundleState(agent, derived);
|
||||
if (current.rootPath && current.mode) {
|
||||
return { adapterConfig: current.config, state: current };
|
||||
const adapterConfig = buildPersistedBundleConfig(derived, current, options);
|
||||
return {
|
||||
adapterConfig,
|
||||
state: deriveBundleState({ ...agent, adapterConfig }),
|
||||
};
|
||||
}
|
||||
|
||||
const managedRoot = resolveManagedInstructionsRoot(agent);
|
||||
|
|
@ -462,7 +553,7 @@ export function agentInstructionsService() {
|
|||
clearLegacyPromptTemplate?: boolean;
|
||||
},
|
||||
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
|
||||
const state = deriveBundleState(agent);
|
||||
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
|
||||
const nextMode = input.mode ?? state.mode ?? "managed";
|
||||
const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile;
|
||||
let nextRootPath: string;
|
||||
|
|
@ -544,7 +635,8 @@ export function agentInstructionsService() {
|
|||
bundle: AgentInstructionsBundle;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
}> {
|
||||
const state = deriveBundleState(agent);
|
||||
const derived = deriveBundleState(agent);
|
||||
const state = await recoverManagedBundleState(agent, derived);
|
||||
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
|
||||
throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file");
|
||||
}
|
||||
|
|
@ -555,8 +647,9 @@ export function agentInstructionsService() {
|
|||
}
|
||||
const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath);
|
||||
await fs.rm(absolutePath, { force: true });
|
||||
const bundle = await getBundle(agent);
|
||||
return { bundle, adapterConfig: state.config };
|
||||
const adapterConfig = buildPersistedBundleConfig(derived, state);
|
||||
const bundle = await getBundle({ ...agent, adapterConfig });
|
||||
return { bundle, adapterConfig };
|
||||
}
|
||||
|
||||
async function exportFiles(agent: AgentLike): Promise<{
|
||||
|
|
@ -564,7 +657,7 @@ export function agentInstructionsService() {
|
|||
entryFile: string;
|
||||
warnings: string[];
|
||||
}> {
|
||||
const state = deriveBundleState(agent);
|
||||
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
|
||||
if (state.rootPath) {
|
||||
const stat = await statIfExists(state.rootPath);
|
||||
if (stat?.isDirectory()) {
|
||||
|
|
|
|||
|
|
@ -132,6 +132,21 @@ export function defaultIssueExecutionWorkspaceSettingsForProject(
|
|||
};
|
||||
}
|
||||
|
||||
export function issueExecutionWorkspaceModeForPersistedWorkspace(
|
||||
mode: string | null | undefined,
|
||||
): IssueExecutionWorkspaceSettings["mode"] {
|
||||
if (mode === null || mode === undefined) {
|
||||
return "agent_default";
|
||||
}
|
||||
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
|
||||
return mode;
|
||||
}
|
||||
if (mode === "adapter_managed" || mode === "cloud_sandbox") {
|
||||
return "agent_default";
|
||||
}
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
export function resolveExecutionWorkspaceMode(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { workspaceOperationService } from "./workspace-operations.js";
|
|||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceMode,
|
||||
|
|
@ -325,6 +326,51 @@ async function resolveLedgerScopeForRun(
|
|||
};
|
||||
}
|
||||
|
||||
type ResumeSessionRow = {
|
||||
sessionParamsJson: Record<string, unknown> | null;
|
||||
sessionDisplayId: string | null;
|
||||
lastRunId: string | null;
|
||||
};
|
||||
|
||||
export function buildExplicitResumeSessionOverride(input: {
|
||||
resumeFromRunId: string;
|
||||
resumeRunSessionIdBefore: string | null;
|
||||
resumeRunSessionIdAfter: string | null;
|
||||
taskSession: ResumeSessionRow | null;
|
||||
sessionCodec: AdapterSessionCodec;
|
||||
}) {
|
||||
const desiredDisplayId = truncateDisplayId(
|
||||
input.resumeRunSessionIdAfter ?? input.resumeRunSessionIdBefore,
|
||||
);
|
||||
const taskSessionParams = normalizeSessionParams(
|
||||
input.sessionCodec.deserialize(input.taskSession?.sessionParamsJson ?? null),
|
||||
);
|
||||
const taskSessionDisplayId = truncateDisplayId(
|
||||
input.taskSession?.sessionDisplayId ??
|
||||
(input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ??
|
||||
readNonEmptyString(taskSessionParams?.sessionId),
|
||||
);
|
||||
const canReuseTaskSessionParams =
|
||||
input.taskSession != null &&
|
||||
(
|
||||
input.taskSession.lastRunId === input.resumeFromRunId ||
|
||||
(!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId)
|
||||
);
|
||||
const sessionParams =
|
||||
canReuseTaskSessionParams
|
||||
? taskSessionParams
|
||||
: desiredDisplayId
|
||||
? { sessionId: desiredDisplayId }
|
||||
: null;
|
||||
const sessionDisplayId = desiredDisplayId ?? (canReuseTaskSessionParams ? taskSessionDisplayId : null);
|
||||
|
||||
if (!sessionDisplayId && !sessionParams) return null;
|
||||
return {
|
||||
sessionDisplayId,
|
||||
sessionParams,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null {
|
||||
if (!usage) return null;
|
||||
return {
|
||||
|
|
@ -977,6 +1023,57 @@ export function heartbeatService(db: Db) {
|
|||
return runtimeForRun?.sessionId ?? null;
|
||||
}
|
||||
|
||||
async function resolveExplicitResumeSessionOverride(
|
||||
agent: typeof agents.$inferSelect,
|
||||
payload: Record<string, unknown> | null,
|
||||
taskKey: string | null,
|
||||
) {
|
||||
const resumeFromRunId = readNonEmptyString(payload?.resumeFromRunId);
|
||||
if (!resumeFromRunId) return null;
|
||||
|
||||
const resumeRun = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
sessionIdBefore: heartbeatRuns.sessionIdBefore,
|
||||
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, resumeFromRunId),
|
||||
eq(heartbeatRuns.companyId, agent.companyId),
|
||||
eq(heartbeatRuns.agentId, agent.id),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!resumeRun) return null;
|
||||
|
||||
const resumeContext = parseObject(resumeRun.contextSnapshot);
|
||||
const resumeTaskKey = deriveTaskKey(resumeContext, null) ?? taskKey;
|
||||
const resumeTaskSession = resumeTaskKey
|
||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, resumeTaskKey)
|
||||
: null;
|
||||
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
|
||||
const sessionOverride = buildExplicitResumeSessionOverride({
|
||||
resumeFromRunId,
|
||||
resumeRunSessionIdBefore: resumeRun.sessionIdBefore,
|
||||
resumeRunSessionIdAfter: resumeRun.sessionIdAfter,
|
||||
taskSession: resumeTaskSession,
|
||||
sessionCodec,
|
||||
});
|
||||
if (!sessionOverride) return null;
|
||||
|
||||
return {
|
||||
resumeFromRunId,
|
||||
taskKey: resumeTaskKey,
|
||||
issueId: readNonEmptyString(resumeContext.issueId),
|
||||
taskId: readNonEmptyString(resumeContext.taskId) ?? readNonEmptyString(resumeContext.issueId),
|
||||
sessionDisplayId: sessionOverride.sessionDisplayId,
|
||||
sessionParams: sessionOverride.sessionParams,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveWorkspaceForRun(
|
||||
agent: typeof agents.$inferSelect,
|
||||
context: Record<string, unknown>,
|
||||
|
|
@ -1920,9 +2017,18 @@ export function heartbeatService(db: Db) {
|
|||
const resetTaskSession = shouldResetTaskSessionForWake(context);
|
||||
const sessionResetReason = describeSessionResetReason(context);
|
||||
const taskSessionForRun = resetTaskSession ? null : taskSession;
|
||||
const previousSessionParams = normalizeSessionParams(
|
||||
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
|
||||
const explicitResumeSessionParams = normalizeSessionParams(
|
||||
sessionCodec.deserialize(parseObject(context.resumeSessionParams)),
|
||||
);
|
||||
const explicitResumeSessionDisplayId = truncateDisplayId(
|
||||
readNonEmptyString(context.resumeSessionDisplayId) ??
|
||||
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(explicitResumeSessionParams) : null) ??
|
||||
readNonEmptyString(explicitResumeSessionParams?.sessionId),
|
||||
);
|
||||
const previousSessionParams =
|
||||
explicitResumeSessionParams ??
|
||||
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
|
||||
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
const executionWorkspaceMode = resolveExecutionWorkspaceMode({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
|
|
@ -2098,11 +2204,29 @@ export function heartbeatService(db: Db) {
|
|||
cleanupReason: null,
|
||||
});
|
||||
}
|
||||
if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
|
||||
await issuesSvc.update(issueId, {
|
||||
executionWorkspaceId: persistedExecutionWorkspace.id,
|
||||
...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}),
|
||||
});
|
||||
if (issueId && persistedExecutionWorkspace) {
|
||||
const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
|
||||
const shouldSwitchIssueToExistingWorkspace =
|
||||
issueRef?.executionWorkspacePreference === "reuse_existing" ||
|
||||
executionWorkspaceMode === "isolated_workspace" ||
|
||||
executionWorkspaceMode === "operator_branch";
|
||||
const nextIssuePatch: Record<string, unknown> = {};
|
||||
if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
|
||||
nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id;
|
||||
}
|
||||
if (resolvedProjectWorkspaceId && issueRef?.projectWorkspaceId !== resolvedProjectWorkspaceId) {
|
||||
nextIssuePatch.projectWorkspaceId = resolvedProjectWorkspaceId;
|
||||
}
|
||||
if (shouldSwitchIssueToExistingWorkspace) {
|
||||
nextIssuePatch.executionWorkspacePreference = "reuse_existing";
|
||||
nextIssuePatch.executionWorkspaceSettings = {
|
||||
...(issueExecutionWorkspaceSettings ?? {}),
|
||||
mode: nextIssueWorkspaceMode,
|
||||
};
|
||||
}
|
||||
if (Object.keys(nextIssuePatch).length > 0) {
|
||||
await issuesSvc.update(issueId, nextIssuePatch);
|
||||
}
|
||||
}
|
||||
if (persistedExecutionWorkspace) {
|
||||
context.executionWorkspaceId = persistedExecutionWorkspace.id;
|
||||
|
|
@ -2171,7 +2295,8 @@ export function heartbeatService(db: Db) {
|
|||
}
|
||||
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
|
||||
let previousSessionDisplayId = truncateDisplayId(
|
||||
taskSessionForRun?.sessionDisplayId ??
|
||||
explicitResumeSessionDisplayId ??
|
||||
taskSessionForRun?.sessionDisplayId ??
|
||||
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
|
||||
readNonEmptyString(runtimeSessionParams?.sessionId) ??
|
||||
runtimeSessionFallback,
|
||||
|
|
@ -2782,7 +2907,9 @@ export function heartbeatService(db: Db) {
|
|||
payload: promotedPayload,
|
||||
});
|
||||
|
||||
const sessionBefore = await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey);
|
||||
const sessionBefore =
|
||||
readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ??
|
||||
await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey);
|
||||
const now = new Date();
|
||||
const newRun = await tx
|
||||
.insert(heartbeatRuns)
|
||||
|
|
@ -2861,10 +2988,30 @@ export function heartbeatService(db: Db) {
|
|||
triggerDetail,
|
||||
payload,
|
||||
});
|
||||
const issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload;
|
||||
let issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload;
|
||||
|
||||
const agent = await getAgent(agentId);
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
const explicitResumeSession = await resolveExplicitResumeSessionOverride(agent, payload, taskKey);
|
||||
if (explicitResumeSession) {
|
||||
enrichedContextSnapshot.resumeFromRunId = explicitResumeSession.resumeFromRunId;
|
||||
enrichedContextSnapshot.resumeSessionDisplayId = explicitResumeSession.sessionDisplayId;
|
||||
enrichedContextSnapshot.resumeSessionParams = explicitResumeSession.sessionParams;
|
||||
if (!readNonEmptyString(enrichedContextSnapshot.issueId) && explicitResumeSession.issueId) {
|
||||
enrichedContextSnapshot.issueId = explicitResumeSession.issueId;
|
||||
}
|
||||
if (!readNonEmptyString(enrichedContextSnapshot.taskId) && explicitResumeSession.taskId) {
|
||||
enrichedContextSnapshot.taskId = explicitResumeSession.taskId;
|
||||
}
|
||||
if (!readNonEmptyString(enrichedContextSnapshot.taskKey) && explicitResumeSession.taskKey) {
|
||||
enrichedContextSnapshot.taskKey = explicitResumeSession.taskKey;
|
||||
}
|
||||
issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueId;
|
||||
}
|
||||
const effectiveTaskKey = readNonEmptyString(enrichedContextSnapshot.taskKey) ?? taskKey;
|
||||
const sessionBefore =
|
||||
explicitResumeSession?.sessionDisplayId ??
|
||||
await resolveSessionBeforeForWakeup(agent, effectiveTaskKey);
|
||||
|
||||
const writeSkippedRequest = async (skipReason: string) => {
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
|
|
@ -2928,7 +3075,6 @@ export function heartbeatService(db: Db) {
|
|||
|
||||
if (issueId && !bypassIssueExecutionLock) {
|
||||
const agentNameKey = normalizeAgentNameKey(agent.name);
|
||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
||||
|
||||
const outcome = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
|
|
@ -3279,8 +3425,6 @@ export function heartbeatService(db: Db) {
|
|||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
||||
|
||||
const newRun = await db
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
assets,
|
||||
companies,
|
||||
|
|
@ -62,6 +63,7 @@ function applyStatusSideEffects(
|
|||
export interface IssueFilters {
|
||||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
|
|
@ -134,6 +136,30 @@ function touchedByUserCondition(companyId: string, userId: string) {
|
|||
`;
|
||||
}
|
||||
|
||||
function participatedByAgentCondition(companyId: string, agentId: string) {
|
||||
return sql<boolean>`
|
||||
(
|
||||
${issues.createdByAgentId} = ${agentId}
|
||||
OR ${issues.assigneeAgentId} = ${agentId}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
AND ${issueComments.authorAgentId} = ${agentId}
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${activityLog}
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
AND ${activityLog.entityId} = ${issues.id}::text
|
||||
AND ${activityLog.agentId} = ${agentId}
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function myLastCommentAtExpr(companyId: string, userId: string) {
|
||||
return sql<Date | null>`
|
||||
(
|
||||
|
|
@ -508,6 +534,9 @@ export function issueService(db: Db) {
|
|||
if (filters?.assigneeAgentId) {
|
||||
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
|
||||
}
|
||||
if (filters?.participantAgentId) {
|
||||
conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
|
||||
}
|
||||
if (filters?.assigneeUserId) {
|
||||
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ Use this when validating Paperclip itself (assignment flow, checkouts, run visib
|
|||
1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`):
|
||||
|
||||
```bash
|
||||
pnpm paperclipai issue create \
|
||||
npx paperclipai issue create \
|
||||
--company-id "$PAPERCLIP_COMPANY_ID" \
|
||||
--title "Self-test: assignment/watch flow" \
|
||||
--description "Temporary validation issue" \
|
||||
|
|
@ -341,19 +341,19 @@ pnpm paperclipai issue create \
|
|||
2. Trigger and watch a heartbeat for that assignee:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID"
|
||||
npx paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID"
|
||||
```
|
||||
|
||||
3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai issue get <issue-id-or-identifier>
|
||||
npx paperclipai issue get <issue-id-or-identifier>
|
||||
```
|
||||
|
||||
4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai issue update <issue-id> --assignee-agent-id <other-agent-id> --status todo
|
||||
npx paperclipai issue update <issue-id> --assignee-agent-id <other-agent-id> --status todo
|
||||
```
|
||||
|
||||
5. Cleanup: mark temporary issues done/cancelled with a clear note.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
|
|||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace.";
|
||||
|
||||
export function CodexLocalConfigFields({
|
||||
mode,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const issuesApi = {
|
|||
status?: string;
|
||||
projectId?: string;
|
||||
assigneeAgentId?: string;
|
||||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
|
|
@ -32,6 +33,7 @@ export const issuesApi = {
|
|||
if (filters?.status) params.set("status", filters.status);
|
||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
|
||||
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
|
||||
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
||||
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
|
||||
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,17 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) {
|
|||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
|
||||
const persistedMode =
|
||||
issue.currentExecutionWorkspace?.mode
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? issue.executionWorkspacePreference;
|
||||
return Boolean(
|
||||
issue.executionWorkspaceId &&
|
||||
(persistedMode === "isolated_workspace" || persistedMode === "operator_branch"),
|
||||
);
|
||||
}
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
issue: Issue;
|
||||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
|
|
@ -269,10 +280,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
? currentProject?.executionWorkspacePolicy ?? null
|
||||
: null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
const currentExecutionWorkspaceSelection =
|
||||
issue.executionWorkspacePreference
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? defaultExecutionWorkspaceModeForProject(currentProject);
|
||||
const { data: reusableExecutionWorkspaces } = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
|
||||
projectId: issue.projectId ?? undefined,
|
||||
|
|
@ -299,9 +306,17 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
}
|
||||
return Array.from(seen.values());
|
||||
}, [reusableExecutionWorkspaces]);
|
||||
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
||||
(workspace) => workspace.id === issue.executionWorkspaceId,
|
||||
);
|
||||
const selectedReusableExecutionWorkspace =
|
||||
deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId)
|
||||
?? issue.currentExecutionWorkspace
|
||||
?? null;
|
||||
const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue)
|
||||
? "reuse_existing"
|
||||
: (
|
||||
issue.executionWorkspacePreference
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? defaultExecutionWorkspaceModeForProject(currentProject)
|
||||
);
|
||||
const projectLink = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
const project = projects?.find((p) => p.id === id) ?? null;
|
||||
|
|
@ -681,7 +696,9 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
>
|
||||
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{option.value === "reuse_existing" && selectedReusableExecutionWorkspace?.mode === "isolated_workspace"
|
||||
? "Existing isolated workspace"
|
||||
: option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -167,6 +167,9 @@ interface IssuesListProps {
|
|||
issueLinkState?: unknown;
|
||||
initialAssignees?: string[];
|
||||
initialSearch?: string;
|
||||
searchFilters?: {
|
||||
participantAgentId?: string;
|
||||
};
|
||||
onSearchChange?: (search: string) => void;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
|
@ -183,6 +186,7 @@ export function IssuesList({
|
|||
issueLinkState,
|
||||
initialAssignees,
|
||||
initialSearch,
|
||||
searchFilters,
|
||||
onSearchChange,
|
||||
onUpdateIssue,
|
||||
}: IssuesListProps) {
|
||||
|
|
@ -240,8 +244,11 @@ export function IssuesList({
|
|||
}, [scopedKey]);
|
||||
|
||||
const { data: searchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }),
|
||||
queryKey: [
|
||||
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||
searchFilters ?? {},
|
||||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
|
||||
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -573,9 +573,9 @@ export function AgentDetail() {
|
|||
});
|
||||
|
||||
const { data: allIssues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(resolvedCompanyId!),
|
||||
queryFn: () => issuesApi.list(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId && needsDashboardData,
|
||||
queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"],
|
||||
queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }),
|
||||
enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData,
|
||||
});
|
||||
|
||||
const { data: allAgents } = useQuery({
|
||||
|
|
@ -593,7 +593,6 @@ export function AgentDetail() {
|
|||
});
|
||||
|
||||
const assignedIssues = (allIssues ?? [])
|
||||
.filter((i) => i.assigneeAgentId === agent?.id)
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
|
||||
|
|
@ -1175,12 +1174,15 @@ function AgentOverview({
|
|||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Recent Issues</h3>
|
||||
<Link to={`/issues?assignee=${agentId}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Link
|
||||
to={`/issues?participantAgentId=${agentId}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
See All →
|
||||
</Link>
|
||||
</div>
|
||||
{assignedIssues.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No assigned issues.</p>
|
||||
<p className="text-sm text-muted-foreground">No recent issues.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg">
|
||||
{assignedIssues.slice(0, 10).map((issue) => (
|
||||
|
|
|
|||
|
|
@ -77,9 +77,64 @@ export function InstanceSettings() {
|
|||
},
|
||||
});
|
||||
|
||||
const disableAllMutation = useMutation({
|
||||
mutationFn: async (agentRows: InstanceSchedulerHeartbeatAgent[]) => {
|
||||
const enabled = agentRows.filter((a) => a.heartbeatEnabled);
|
||||
if (enabled.length === 0) return enabled;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
enabled.map(async (agentRow) => {
|
||||
const agent = await agentsApi.get(agentRow.id, agentRow.companyId);
|
||||
const runtimeConfig = asRecord(agent.runtimeConfig) ?? {};
|
||||
const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {};
|
||||
await agentsApi.update(
|
||||
agentRow.id,
|
||||
{
|
||||
runtimeConfig: {
|
||||
...runtimeConfig,
|
||||
heartbeat: { ...heartbeat, enabled: false },
|
||||
},
|
||||
},
|
||||
agentRow.companyId,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||
if (failures.length > 0) {
|
||||
const firstError = failures[0]?.reason;
|
||||
const detail = firstError instanceof Error ? firstError.message : "Unknown error";
|
||||
throw new Error(
|
||||
failures.length === 1
|
||||
? `Failed to disable 1 timer heartbeat: ${detail}`
|
||||
: `Failed to disable ${failures.length} of ${enabled.length} timer heartbeats. First error: ${detail}`,
|
||||
);
|
||||
}
|
||||
return enabled;
|
||||
},
|
||||
onSuccess: async (updatedRows) => {
|
||||
setActionError(null);
|
||||
const companies = new Set(updatedRows.map((row) => row.companyId));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }),
|
||||
...Array.from(companies, (companyId) =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }),
|
||||
),
|
||||
...updatedRows.map((row) =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(row.id) }),
|
||||
),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to disable all heartbeats.");
|
||||
},
|
||||
});
|
||||
|
||||
const agents = heartbeatsQuery.data ?? [];
|
||||
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
|
||||
const disabledCount = agents.length - activeCount;
|
||||
const enabledCount = agents.filter((agent) => agent.heartbeatEnabled).length;
|
||||
const anyEnabled = enabledCount > 0;
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
|
||||
|
|
@ -120,10 +175,27 @@ export function InstanceSettings() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span><span className="font-semibold text-foreground">{activeCount}</span> active</span>
|
||||
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</span>
|
||||
<span><span className="font-semibold text-foreground">{grouped.length}</span> {grouped.length === 1 ? "company" : "companies"}</span>
|
||||
{anyEnabled && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="ml-auto h-7 text-xs"
|
||||
disabled={disableAllMutation.isPending}
|
||||
onClick={() => {
|
||||
const noun = enabledCount === 1 ? "agent" : "agents";
|
||||
if (!window.confirm(`Disable timer heartbeats for all ${enabledCount} enabled ${noun}?`)) {
|
||||
return;
|
||||
}
|
||||
disableAllMutation.mutate(agents);
|
||||
}}
|
||||
>
|
||||
{disableAllMutation.isPending ? "Disabling..." : "Disable All"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function Issues() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
const initialSearch = searchParams.get("q") ?? "";
|
||||
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const handleSearchChange = useCallback((search: string) => {
|
||||
clearTimeout(debounceRef.current);
|
||||
|
|
@ -86,8 +87,8 @@ export function Issues() {
|
|||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
|
|
@ -117,6 +118,7 @@ export function Issues() {
|
|||
initialSearch={initialSearch}
|
||||
onSearchChange={handleSearchChange}
|
||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||
searchFilters={participantAgentId ? { participantAgentId } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue