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.
|
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
|
## Environment Test
|
||||||
|
|
||||||
The environment test checks:
|
The environment test checks:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ Operational fields:
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
- 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.
|
- 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).
|
- 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).
|
- 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 = (() => {
|
const commandNotes = (() => {
|
||||||
if (!instructionsFilePath) return [] as string[];
|
if (!instructionsFilePath) {
|
||||||
|
return [repoAgentsNote];
|
||||||
|
}
|
||||||
if (instructionsPrefix.length > 0) {
|
if (instructionsPrefix.length > 0) {
|
||||||
return [
|
return [
|
||||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||||
|
repoAgentsNote,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||||
|
repoAgentsNote,
|
||||||
];
|
];
|
||||||
})();
|
})();
|
||||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export const updateAgentSchema = createAgentSchema
|
||||||
.partial()
|
.partial()
|
||||||
.extend({
|
.extend({
|
||||||
permissions: z.never().optional(),
|
permissions: z.never().optional(),
|
||||||
|
replaceAdapterConfig: z.boolean().optional(),
|
||||||
status: z.enum(AGENT_STATUSES).optional(),
|
status: z.enum(AGENT_STATUSES).optional(),
|
||||||
spentMonthlyCents: z.number().int().nonnegative().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 { createInterface } from "node:readline/promises";
|
||||||
import { stdin, stdout } from "node:process";
|
import { stdin, stdout } from "node:process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||||
|
|
||||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||||
const cliArgs = process.argv.slice(3);
|
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 devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
|
||||||
|
|
||||||
const watchedDirectories = [
|
const watchedDirectories = [
|
||||||
".paperclip",
|
|
||||||
"cli",
|
"cli",
|
||||||
"scripts",
|
"scripts",
|
||||||
"server",
|
"server",
|
||||||
|
|
@ -165,6 +165,7 @@ function readSignature(absolutePath) {
|
||||||
function addFileToSnapshot(snapshot, absolutePath) {
|
function addFileToSnapshot(snapshot, absolutePath) {
|
||||||
const relativePath = toRelativePath(absolutePath);
|
const relativePath = toRelativePath(absolutePath);
|
||||||
if (ignoredRelativePaths.has(relativePath)) return;
|
if (ignoredRelativePaths.has(relativePath)) return;
|
||||||
|
if (!shouldTrackDevServerPath(relativePath)) return;
|
||||||
snapshot.set(relativePath, readSignature(absolutePath));
|
snapshot.set(relativePath, readSignature(absolutePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -197,4 +197,122 @@ describe("agent instructions bundle routes", () => {
|
||||||
expect.any(Object),
|
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",
|
"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 () => {
|
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 root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
|
||||||
const workspace = path.join(root, "workspace");
|
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,
|
buildExecutionWorkspaceAdapterConfig,
|
||||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||||
gateProjectExecutionWorkspacePolicy,
|
gateProjectExecutionWorkspacePolicy,
|
||||||
|
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||||
parseIssueExecutionWorkspaceSettings,
|
parseIssueExecutionWorkspaceSettings,
|
||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
resolveExecutionWorkspaceMode,
|
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", () => {
|
it("disables project execution workspace policy when the instance flag is off", () => {
|
||||||
expect(
|
expect(
|
||||||
gateProjectExecutionWorkspacePolicy(
|
gateProjectExecutionWorkspacePolicy(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { agents } from "@paperclipai/db";
|
import type { agents } from "@paperclipai/db";
|
||||||
|
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
|
||||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||||
import {
|
import {
|
||||||
|
buildExplicitResumeSessionOverride,
|
||||||
formatRuntimeWorkspaceWarningLog,
|
formatRuntimeWorkspaceWarningLog,
|
||||||
prioritizeProjectWorkspaceCandidatesForRun,
|
prioritizeProjectWorkspaceCandidatesForRun,
|
||||||
parseSessionCompactionPolicy,
|
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", () => {
|
describe("formatRuntimeWorkspaceWarningLog", () => {
|
||||||
it("emits informational workspace warnings on stdout", () => {
|
it("emits informational workspace warnings on stdout", () => {
|
||||||
expect(formatRuntimeWorkspaceWarningLog("Using fallback workspace")).toEqual({
|
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,
|
workspaceOperationService,
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.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 { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||||
import { redactEventPayload } from "../redaction.js";
|
import { redactEventPayload } from "../redaction.js";
|
||||||
import { redactCurrentUserValue } from "../log-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 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_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
|
||||||
|
const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [
|
||||||
|
"instructionsBundleMode",
|
||||||
|
"instructionsRootPath",
|
||||||
|
"instructionsEntryFile",
|
||||||
|
"instructionsFilePath",
|
||||||
|
"agentsMdPath",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = agentService(db);
|
const svc = agentService(db);
|
||||||
|
|
@ -303,6 +310,24 @@ export function agentRoutes(db: Db) {
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
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 {
|
function parseBooleanLike(value: unknown): boolean | null {
|
||||||
if (typeof value === "boolean") return value;
|
if (typeof value === "boolean") return value;
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
|
|
@ -830,17 +855,7 @@ export function agentRoutes(db: Db) {
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/instance/scheduler-heartbeats", async (req, res) => {
|
router.get("/instance/scheduler-heartbeats", async (req, res) => {
|
||||||
assertBoard(req);
|
assertInstanceAdmin(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));
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
|
|
@ -858,7 +873,6 @@ export function agentRoutes(db: Db) {
|
||||||
})
|
})
|
||||||
.from(agentsTable)
|
.from(agentsTable)
|
||||||
.innerJoin(companies, eq(agentsTable.companyId, companies.id))
|
.innerJoin(companies, eq(agentsTable.companyId, companies.id))
|
||||||
.where(accessConditions.length > 0 ? and(...accessConditions) : undefined)
|
|
||||||
.orderBy(companies.name, agentsTable.name);
|
.orderBy(companies.name, agentsTable.name);
|
||||||
|
|
||||||
const items: InstanceSchedulerHeartbeatAgent[] = rows
|
const items: InstanceSchedulerHeartbeatAgent[] = rows
|
||||||
|
|
@ -887,7 +901,6 @@ export function agentRoutes(db: Db) {
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((item) =>
|
.filter((item) =>
|
||||||
item.intervalSec > 0 &&
|
|
||||||
item.status !== "paused" &&
|
item.status !== "paused" &&
|
||||||
item.status !== "terminated" &&
|
item.status !== "terminated" &&
|
||||||
item.status !== "pending_approval",
|
item.status !== "pending_approval",
|
||||||
|
|
@ -1689,6 +1702,8 @@ export function agentRoutes(db: Db) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchData = { ...(req.body as Record<string, unknown>) };
|
const patchData = { ...(req.body as Record<string, unknown>) };
|
||||||
|
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
|
||||||
|
delete patchData.replaceAdapterConfig;
|
||||||
if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
|
if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
|
||||||
const adapterConfig = asRecord(patchData.adapterConfig);
|
const adapterConfig = asRecord(patchData.adapterConfig);
|
||||||
if (!adapterConfig) {
|
if (!adapterConfig) {
|
||||||
|
|
@ -1710,9 +1725,31 @@ export function agentRoutes(db: Db) {
|
||||||
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
||||||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
||||||
if (touchesAdapterConfiguration) {
|
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(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(
|
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||||
requestedAdapterType,
|
requestedAdapterType,
|
||||||
rawEffectiveAdapterConfig,
|
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) {
|
export function assertCompanyAccess(req: Request, companyId: string) {
|
||||||
if (req.actor.type === "none") {
|
if (req.actor.type === "none") {
|
||||||
throw unauthorized();
|
throw unauthorized();
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
const result = await svc.list(companyId, {
|
const result = await svc.list(companyId, {
|
||||||
status: req.query.status as string | undefined,
|
status: req.query.status as string | undefined,
|
||||||
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
||||||
|
participantAgentId: req.query.participantAgentId as string | undefined,
|
||||||
assigneeUserId,
|
assigneeUserId,
|
||||||
touchedByUserId,
|
touchedByUserId,
|
||||||
unreadForUserId,
|
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 {
|
function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle {
|
||||||
const nextFiles = [...files];
|
const nextFiles = [...files];
|
||||||
if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) {
|
if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) {
|
||||||
|
|
@ -327,6 +383,36 @@ function applyBundleConfig(
|
||||||
return next;
|
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(
|
async function writeBundleFiles(
|
||||||
rootPath: string,
|
rootPath: string,
|
||||||
files: Record<string, string>,
|
files: Record<string, string>,
|
||||||
|
|
@ -366,7 +452,7 @@ export function syncInstructionsBundleConfigFromFilePath(
|
||||||
|
|
||||||
export function agentInstructionsService() {
|
export function agentInstructionsService() {
|
||||||
async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> {
|
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, []);
|
if (!state.rootPath) return toBundle(agent, state, []);
|
||||||
const stat = await statIfExists(state.rootPath);
|
const stat = await statIfExists(state.rootPath);
|
||||||
if (!stat?.isDirectory()) {
|
if (!stat?.isDirectory()) {
|
||||||
|
|
@ -381,7 +467,7 @@ export function agentInstructionsService() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
|
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) {
|
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
|
||||||
const content = asString(state.config[PROMPT_KEY]);
|
const content = asString(state.config[PROMPT_KEY]);
|
||||||
if (content === null) throw notFound("Instructions file not found");
|
if (content === null) throw notFound("Instructions file not found");
|
||||||
|
|
@ -422,9 +508,14 @@ export function agentInstructionsService() {
|
||||||
agent: AgentLike,
|
agent: AgentLike,
|
||||||
options?: { clearLegacyPromptTemplate?: boolean },
|
options?: { clearLegacyPromptTemplate?: boolean },
|
||||||
): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> {
|
): 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) {
|
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);
|
const managedRoot = resolveManagedInstructionsRoot(agent);
|
||||||
|
|
@ -462,7 +553,7 @@ export function agentInstructionsService() {
|
||||||
clearLegacyPromptTemplate?: boolean;
|
clearLegacyPromptTemplate?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
|
): 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 nextMode = input.mode ?? state.mode ?? "managed";
|
||||||
const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile;
|
const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile;
|
||||||
let nextRootPath: string;
|
let nextRootPath: string;
|
||||||
|
|
@ -544,7 +635,8 @@ export function agentInstructionsService() {
|
||||||
bundle: AgentInstructionsBundle;
|
bundle: AgentInstructionsBundle;
|
||||||
adapterConfig: Record<string, unknown>;
|
adapterConfig: Record<string, unknown>;
|
||||||
}> {
|
}> {
|
||||||
const state = deriveBundleState(agent);
|
const derived = deriveBundleState(agent);
|
||||||
|
const state = await recoverManagedBundleState(agent, derived);
|
||||||
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
|
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
|
||||||
throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file");
|
throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file");
|
||||||
}
|
}
|
||||||
|
|
@ -555,8 +647,9 @@ export function agentInstructionsService() {
|
||||||
}
|
}
|
||||||
const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath);
|
const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath);
|
||||||
await fs.rm(absolutePath, { force: true });
|
await fs.rm(absolutePath, { force: true });
|
||||||
const bundle = await getBundle(agent);
|
const adapterConfig = buildPersistedBundleConfig(derived, state);
|
||||||
return { bundle, adapterConfig: state.config };
|
const bundle = await getBundle({ ...agent, adapterConfig });
|
||||||
|
return { bundle, adapterConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportFiles(agent: AgentLike): Promise<{
|
async function exportFiles(agent: AgentLike): Promise<{
|
||||||
|
|
@ -564,7 +657,7 @@ export function agentInstructionsService() {
|
||||||
entryFile: string;
|
entryFile: string;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}> {
|
}> {
|
||||||
const state = deriveBundleState(agent);
|
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
|
||||||
if (state.rootPath) {
|
if (state.rootPath) {
|
||||||
const stat = await statIfExists(state.rootPath);
|
const stat = await statIfExists(state.rootPath);
|
||||||
if (stat?.isDirectory()) {
|
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: {
|
export function resolveExecutionWorkspaceMode(input: {
|
||||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import { workspaceOperationService } from "./workspace-operations.js";
|
||||||
import {
|
import {
|
||||||
buildExecutionWorkspaceAdapterConfig,
|
buildExecutionWorkspaceAdapterConfig,
|
||||||
gateProjectExecutionWorkspacePolicy,
|
gateProjectExecutionWorkspacePolicy,
|
||||||
|
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||||
parseIssueExecutionWorkspaceSettings,
|
parseIssueExecutionWorkspaceSettings,
|
||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
resolveExecutionWorkspaceMode,
|
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 {
|
function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null {
|
||||||
if (!usage) return null;
|
if (!usage) return null;
|
||||||
return {
|
return {
|
||||||
|
|
@ -977,6 +1023,57 @@ export function heartbeatService(db: Db) {
|
||||||
return runtimeForRun?.sessionId ?? null;
|
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(
|
async function resolveWorkspaceForRun(
|
||||||
agent: typeof agents.$inferSelect,
|
agent: typeof agents.$inferSelect,
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
|
|
@ -1920,9 +2017,18 @@ export function heartbeatService(db: Db) {
|
||||||
const resetTaskSession = shouldResetTaskSessionForWake(context);
|
const resetTaskSession = shouldResetTaskSessionForWake(context);
|
||||||
const sessionResetReason = describeSessionResetReason(context);
|
const sessionResetReason = describeSessionResetReason(context);
|
||||||
const taskSessionForRun = resetTaskSession ? null : taskSession;
|
const taskSessionForRun = resetTaskSession ? null : taskSession;
|
||||||
const previousSessionParams = normalizeSessionParams(
|
const explicitResumeSessionParams = normalizeSessionParams(
|
||||||
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
|
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 config = parseObject(agent.adapterConfig);
|
||||||
const executionWorkspaceMode = resolveExecutionWorkspaceMode({
|
const executionWorkspaceMode = resolveExecutionWorkspaceMode({
|
||||||
projectPolicy: projectExecutionWorkspacePolicy,
|
projectPolicy: projectExecutionWorkspacePolicy,
|
||||||
|
|
@ -2098,11 +2204,29 @@ export function heartbeatService(db: Db) {
|
||||||
cleanupReason: null,
|
cleanupReason: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
|
if (issueId && persistedExecutionWorkspace) {
|
||||||
await issuesSvc.update(issueId, {
|
const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
|
||||||
executionWorkspaceId: persistedExecutionWorkspace.id,
|
const shouldSwitchIssueToExistingWorkspace =
|
||||||
...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}),
|
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) {
|
if (persistedExecutionWorkspace) {
|
||||||
context.executionWorkspaceId = persistedExecutionWorkspace.id;
|
context.executionWorkspaceId = persistedExecutionWorkspace.id;
|
||||||
|
|
@ -2171,7 +2295,8 @@ export function heartbeatService(db: Db) {
|
||||||
}
|
}
|
||||||
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
|
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
|
||||||
let previousSessionDisplayId = truncateDisplayId(
|
let previousSessionDisplayId = truncateDisplayId(
|
||||||
taskSessionForRun?.sessionDisplayId ??
|
explicitResumeSessionDisplayId ??
|
||||||
|
taskSessionForRun?.sessionDisplayId ??
|
||||||
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
|
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
|
||||||
readNonEmptyString(runtimeSessionParams?.sessionId) ??
|
readNonEmptyString(runtimeSessionParams?.sessionId) ??
|
||||||
runtimeSessionFallback,
|
runtimeSessionFallback,
|
||||||
|
|
@ -2782,7 +2907,9 @@ export function heartbeatService(db: Db) {
|
||||||
payload: promotedPayload,
|
payload: promotedPayload,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionBefore = await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey);
|
const sessionBefore =
|
||||||
|
readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ??
|
||||||
|
await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const newRun = await tx
|
const newRun = await tx
|
||||||
.insert(heartbeatRuns)
|
.insert(heartbeatRuns)
|
||||||
|
|
@ -2861,10 +2988,30 @@ export function heartbeatService(db: Db) {
|
||||||
triggerDetail,
|
triggerDetail,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
const issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload;
|
let issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload;
|
||||||
|
|
||||||
const agent = await getAgent(agentId);
|
const agent = await getAgent(agentId);
|
||||||
if (!agent) throw notFound("Agent not found");
|
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) => {
|
const writeSkippedRequest = async (skipReason: string) => {
|
||||||
await db.insert(agentWakeupRequests).values({
|
await db.insert(agentWakeupRequests).values({
|
||||||
|
|
@ -2928,7 +3075,6 @@ export function heartbeatService(db: Db) {
|
||||||
|
|
||||||
if (issueId && !bypassIssueExecutionLock) {
|
if (issueId && !bypassIssueExecutionLock) {
|
||||||
const agentNameKey = normalizeAgentNameKey(agent.name);
|
const agentNameKey = normalizeAgentNameKey(agent.name);
|
||||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
|
||||||
|
|
||||||
const outcome = await db.transaction(async (tx) => {
|
const outcome = await db.transaction(async (tx) => {
|
||||||
await tx.execute(
|
await tx.execute(
|
||||||
|
|
@ -3279,8 +3425,6 @@ export function heartbeatService(db: Db) {
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
|
||||||
|
|
||||||
const newRun = await db
|
const newRun = await db
|
||||||
.insert(heartbeatRuns)
|
.insert(heartbeatRuns)
|
||||||
.values({
|
.values({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
|
activityLog,
|
||||||
agents,
|
agents,
|
||||||
assets,
|
assets,
|
||||||
companies,
|
companies,
|
||||||
|
|
@ -62,6 +63,7 @@ function applyStatusSideEffects(
|
||||||
export interface IssueFilters {
|
export interface IssueFilters {
|
||||||
status?: string;
|
status?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
|
participantAgentId?: string;
|
||||||
assigneeUserId?: string;
|
assigneeUserId?: string;
|
||||||
touchedByUserId?: string;
|
touchedByUserId?: string;
|
||||||
unreadForUserId?: 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) {
|
function myLastCommentAtExpr(companyId: string, userId: string) {
|
||||||
return sql<Date | null>`
|
return sql<Date | null>`
|
||||||
(
|
(
|
||||||
|
|
@ -508,6 +534,9 @@ export function issueService(db: Db) {
|
||||||
if (filters?.assigneeAgentId) {
|
if (filters?.assigneeAgentId) {
|
||||||
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
|
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
|
||||||
}
|
}
|
||||||
|
if (filters?.participantAgentId) {
|
||||||
|
conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
|
||||||
|
}
|
||||||
if (filters?.assigneeUserId) {
|
if (filters?.assigneeUserId) {
|
||||||
conditions.push(eq(issues.assigneeUserId, 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`):
|
1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm paperclipai issue create \
|
npx paperclipai issue create \
|
||||||
--company-id "$PAPERCLIP_COMPANY_ID" \
|
--company-id "$PAPERCLIP_COMPANY_ID" \
|
||||||
--title "Self-test: assignment/watch flow" \
|
--title "Self-test: assignment/watch flow" \
|
||||||
--description "Temporary validation issue" \
|
--description "Temporary validation issue" \
|
||||||
|
|
@ -341,19 +341,19 @@ pnpm paperclipai issue create \
|
||||||
2. Trigger and watch a heartbeat for that assignee:
|
2. Trigger and watch a heartbeat for that assignee:
|
||||||
|
|
||||||
```bash
|
```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:
|
3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted:
|
||||||
|
|
||||||
```bash
|
```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:
|
4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior:
|
||||||
|
|
||||||
```bash
|
```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.
|
5. Cleanup: mark temporary issues done/cancelled with a clear note.
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
|
||||||
const inputClass =
|
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";
|
"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 =
|
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({
|
export function CodexLocalConfigFields({
|
||||||
mode,
|
mode,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export const issuesApi = {
|
||||||
status?: string;
|
status?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
|
participantAgentId?: string;
|
||||||
assigneeUserId?: string;
|
assigneeUserId?: string;
|
||||||
touchedByUserId?: string;
|
touchedByUserId?: string;
|
||||||
unreadForUserId?: string;
|
unreadForUserId?: string;
|
||||||
|
|
@ -32,6 +33,7 @@ export const issuesApi = {
|
||||||
if (filters?.status) params.set("status", filters.status);
|
if (filters?.status) params.set("status", filters.status);
|
||||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||||
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
|
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?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
||||||
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
|
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
|
||||||
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,17 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) {
|
||||||
return "shared_workspace";
|
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 {
|
interface IssuePropertiesProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
onUpdate: (data: Record<string, unknown>) => void;
|
onUpdate: (data: Record<string, unknown>) => void;
|
||||||
|
|
@ -269,10 +280,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
? currentProject?.executionWorkspacePolicy ?? null
|
? currentProject?.executionWorkspacePolicy ?? null
|
||||||
: null;
|
: null;
|
||||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||||
const currentExecutionWorkspaceSelection =
|
|
||||||
issue.executionWorkspacePreference
|
|
||||||
?? issue.executionWorkspaceSettings?.mode
|
|
||||||
?? defaultExecutionWorkspaceModeForProject(currentProject);
|
|
||||||
const { data: reusableExecutionWorkspaces } = useQuery({
|
const { data: reusableExecutionWorkspaces } = useQuery({
|
||||||
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
|
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
|
||||||
projectId: issue.projectId ?? undefined,
|
projectId: issue.projectId ?? undefined,
|
||||||
|
|
@ -299,9 +306,17 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
}
|
}
|
||||||
return Array.from(seen.values());
|
return Array.from(seen.values());
|
||||||
}, [reusableExecutionWorkspaces]);
|
}, [reusableExecutionWorkspaces]);
|
||||||
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
const selectedReusableExecutionWorkspace =
|
||||||
(workspace) => workspace.id === issue.executionWorkspaceId,
|
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) => {
|
const projectLink = (id: string | null) => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const project = projects?.find((p) => p.id === id) ?? 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) => (
|
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.value === "reuse_existing" && selectedReusableExecutionWorkspace?.mode === "isolated_workspace"
|
||||||
|
? "Existing isolated workspace"
|
||||||
|
: option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,9 @@ interface IssuesListProps {
|
||||||
issueLinkState?: unknown;
|
issueLinkState?: unknown;
|
||||||
initialAssignees?: string[];
|
initialAssignees?: string[];
|
||||||
initialSearch?: string;
|
initialSearch?: string;
|
||||||
|
searchFilters?: {
|
||||||
|
participantAgentId?: string;
|
||||||
|
};
|
||||||
onSearchChange?: (search: string) => void;
|
onSearchChange?: (search: string) => void;
|
||||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +186,7 @@ export function IssuesList({
|
||||||
issueLinkState,
|
issueLinkState,
|
||||||
initialAssignees,
|
initialAssignees,
|
||||||
initialSearch,
|
initialSearch,
|
||||||
|
searchFilters,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onUpdateIssue,
|
onUpdateIssue,
|
||||||
}: IssuesListProps) {
|
}: IssuesListProps) {
|
||||||
|
|
@ -240,8 +244,11 @@ export function IssuesList({
|
||||||
}, [scopedKey]);
|
}, [scopedKey]);
|
||||||
|
|
||||||
const { data: searchedIssues = [] } = useQuery({
|
const { data: searchedIssues = [] } = useQuery({
|
||||||
queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
queryKey: [
|
||||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }),
|
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||||
|
searchFilters ?? {},
|
||||||
|
],
|
||||||
|
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
|
||||||
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -573,9 +573,9 @@ export function AgentDetail() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: allIssues } = useQuery({
|
const { data: allIssues } = useQuery({
|
||||||
queryKey: queryKeys.issues.list(resolvedCompanyId!),
|
queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"],
|
||||||
queryFn: () => issuesApi.list(resolvedCompanyId!),
|
queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }),
|
||||||
enabled: !!resolvedCompanyId && needsDashboardData,
|
enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: allAgents } = useQuery({
|
const { data: allAgents } = useQuery({
|
||||||
|
|
@ -593,7 +593,6 @@ export function AgentDetail() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const assignedIssues = (allIssues ?? [])
|
const assignedIssues = (allIssues ?? [])
|
||||||
.filter((i) => i.assigneeAgentId === agent?.id)
|
|
||||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
||||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
|
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="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium">Recent Issues</h3>
|
<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 →
|
See All →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{assignedIssues.length === 0 ? (
|
{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">
|
<div className="border border-border rounded-lg">
|
||||||
{assignedIssues.slice(0, 10).map((issue) => (
|
{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 agents = heartbeatsQuery.data ?? [];
|
||||||
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
|
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
|
||||||
const disabledCount = agents.length - activeCount;
|
const disabledCount = agents.length - activeCount;
|
||||||
|
const enabledCount = agents.filter((agent) => agent.heartbeatEnabled).length;
|
||||||
|
const anyEnabled = enabledCount > 0;
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
|
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
|
||||||
|
|
@ -120,10 +175,27 @@ export function InstanceSettings() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">{activeCount}</span> active</span>
|
||||||
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{actionError && (
|
{actionError && (
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export function Issues() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const initialSearch = searchParams.get("q") ?? "";
|
const initialSearch = searchParams.get("q") ?? "";
|
||||||
|
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const handleSearchChange = useCallback((search: string) => {
|
const handleSearchChange = useCallback((search: string) => {
|
||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
|
|
@ -86,8 +87,8 @@ export function Issues() {
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const { data: issues, isLoading, error } = useQuery({
|
const { data: issues, isLoading, error } = useQuery({
|
||||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"],
|
||||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -117,6 +118,7 @@ export function Issues() {
|
||||||
initialSearch={initialSearch}
|
initialSearch={initialSearch}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||||
|
searchFilters={participantAgentId ? { participantAgentId } : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue