mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
merge master into pap-1078-qol-fixes
Resolve the keyboard shortcut conflicts after [#2539](https://github.com/paperclipai/paperclip/pull/2539) and [#2540](https://github.com/paperclipai/paperclip/pull/2540), keep the release package rewrite working with cliVersion, and stabilize the provisioning timeout in the full suite. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
249
cli/src/__tests__/routines.test.ts
Normal file
249
cli/src/__tests__/routines.test.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
agents,
|
||||||
|
companies,
|
||||||
|
createDb,
|
||||||
|
projects,
|
||||||
|
routines,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
getEmbeddedPostgresTestSupport,
|
||||||
|
startEmbeddedPostgresTestDatabase,
|
||||||
|
} from "./helpers/embedded-postgres.js";
|
||||||
|
import { disableAllRoutinesInConfig } from "../commands/routines.js";
|
||||||
|
|
||||||
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
|
||||||
|
if (!embeddedPostgresSupport.supported) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping embedded Postgres routines CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTestConfig(configPath: string, tempRoot: string, connectionString: string) {
|
||||||
|
const config = {
|
||||||
|
$meta: {
|
||||||
|
version: 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
source: "doctor" as const,
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
mode: "postgres" as const,
|
||||||
|
connectionString,
|
||||||
|
embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
|
||||||
|
embeddedPostgresPort: 54329,
|
||||||
|
backup: {
|
||||||
|
enabled: false,
|
||||||
|
intervalMinutes: 60,
|
||||||
|
retentionDays: 30,
|
||||||
|
dir: path.join(tempRoot, "backups"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
mode: "file" as const,
|
||||||
|
logDir: path.join(tempRoot, "logs"),
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
deploymentMode: "local_trusted" as const,
|
||||||
|
exposure: "private" as const,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 3100,
|
||||||
|
allowedHostnames: [],
|
||||||
|
serveUi: false,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
baseUrlMode: "auto" as const,
|
||||||
|
disableSignUp: false,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
provider: "local_disk" as const,
|
||||||
|
localDisk: {
|
||||||
|
baseDir: path.join(tempRoot, "storage"),
|
||||||
|
},
|
||||||
|
s3: {
|
||||||
|
bucket: "paperclip",
|
||||||
|
region: "us-east-1",
|
||||||
|
prefix: "",
|
||||||
|
forcePathStyle: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secrets: {
|
||||||
|
provider: "local_encrypted" as const,
|
||||||
|
strictMode: false,
|
||||||
|
localEncrypted: {
|
||||||
|
keyFilePath: path.join(tempRoot, "secrets", "master.key"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mkdirSync(path.dirname(configPath), { recursive: true });
|
||||||
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describeEmbeddedPostgres("disableAllRoutinesInConfig", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
let tempRoot = "";
|
||||||
|
let configPath = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-cli-db-");
|
||||||
|
db = createDb(tempDb.connectionString);
|
||||||
|
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-cli-config-"));
|
||||||
|
configPath = path.join(tempRoot, "config.json");
|
||||||
|
writeTestConfig(configPath, tempRoot, tempDb.connectionString);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(routines);
|
||||||
|
await db.delete(projects);
|
||||||
|
await db.delete(agents);
|
||||||
|
await db.delete(companies);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempDb?.cleanup();
|
||||||
|
if (tempRoot) {
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauses only non-archived routines for the selected company", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const otherCompanyId = randomUUID();
|
||||||
|
const projectId = randomUUID();
|
||||||
|
const otherProjectId = randomUUID();
|
||||||
|
const agentId = randomUUID();
|
||||||
|
const otherAgentId = randomUUID();
|
||||||
|
const activeRoutineId = randomUUID();
|
||||||
|
const pausedRoutineId = randomUUID();
|
||||||
|
const archivedRoutineId = randomUUID();
|
||||||
|
const otherCompanyRoutineId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(companies).values([
|
||||||
|
{
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherCompanyId,
|
||||||
|
name: "Other company",
|
||||||
|
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(agents).values([
|
||||||
|
{
|
||||||
|
id: agentId,
|
||||||
|
companyId,
|
||||||
|
name: "Coder",
|
||||||
|
adapterType: "process",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherAgentId,
|
||||||
|
companyId: otherCompanyId,
|
||||||
|
name: "Other coder",
|
||||||
|
adapterType: "process",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(projects).values([
|
||||||
|
{
|
||||||
|
id: projectId,
|
||||||
|
companyId,
|
||||||
|
name: "Project",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherProjectId,
|
||||||
|
companyId: otherCompanyId,
|
||||||
|
name: "Other project",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(routines).values([
|
||||||
|
{
|
||||||
|
id: activeRoutineId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
title: "Active routine",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: pausedRoutineId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
title: "Paused routine",
|
||||||
|
status: "paused",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: archivedRoutineId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
title: "Archived routine",
|
||||||
|
status: "archived",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherCompanyRoutineId,
|
||||||
|
companyId: otherCompanyId,
|
||||||
|
projectId: otherProjectId,
|
||||||
|
assigneeAgentId: otherAgentId,
|
||||||
|
title: "Other company routine",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await disableAllRoutinesInConfig({
|
||||||
|
config: configPath,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
companyId,
|
||||||
|
totalRoutines: 3,
|
||||||
|
pausedCount: 1,
|
||||||
|
alreadyPausedCount: 1,
|
||||||
|
archivedCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const companyRoutines = await db
|
||||||
|
.select({
|
||||||
|
id: routines.id,
|
||||||
|
status: routines.status,
|
||||||
|
})
|
||||||
|
.from(routines)
|
||||||
|
.where(eq(routines.companyId, companyId));
|
||||||
|
const statusById = new Map(companyRoutines.map((routine) => [routine.id, routine.status]));
|
||||||
|
|
||||||
|
expect(statusById.get(activeRoutineId)).toBe("paused");
|
||||||
|
expect(statusById.get(pausedRoutineId)).toBe("paused");
|
||||||
|
expect(statusById.get(archivedRoutineId)).toBe("archived");
|
||||||
|
|
||||||
|
const otherCompanyRoutine = await db
|
||||||
|
.select({
|
||||||
|
status: routines.status,
|
||||||
|
})
|
||||||
|
.from(routines)
|
||||||
|
.where(eq(routines.id, otherCompanyRoutineId));
|
||||||
|
expect(otherCompanyRoutine[0]?.status).toBe("active");
|
||||||
|
});
|
||||||
|
});
|
||||||
352
cli/src/commands/routines.ts
Normal file
352
cli/src/commands/routines.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Command } from "commander";
|
||||||
|
import pc from "picocolors";
|
||||||
|
import {
|
||||||
|
applyPendingMigrations,
|
||||||
|
createDb,
|
||||||
|
createEmbeddedPostgresLogBuffer,
|
||||||
|
ensurePostgresDatabase,
|
||||||
|
formatEmbeddedPostgresError,
|
||||||
|
routines,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
|
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||||
|
|
||||||
|
type RoutinesDisableAllOptions = {
|
||||||
|
config?: string;
|
||||||
|
dataDir?: string;
|
||||||
|
companyId?: string;
|
||||||
|
json?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DisableAllRoutinesResult = {
|
||||||
|
companyId: string;
|
||||||
|
totalRoutines: number;
|
||||||
|
pausedCount: number;
|
||||||
|
alreadyPausedCount: number;
|
||||||
|
archivedCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
type EmbeddedPostgresHandle = {
|
||||||
|
port: number;
|
||||||
|
startedByThisProcess: boolean;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClosableDb = ReturnType<typeof createDb> & {
|
||||||
|
$client?: {
|
||||||
|
end?: (options?: { timeout?: number }) => Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function nonEmpty(value: string | null | undefined): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isPortAvailable(port: number): Promise<boolean> {
|
||||||
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.once("error", () => resolve(false));
|
||||||
|
server.listen(port, "127.0.0.1", () => {
|
||||||
|
server.close(() => resolve(true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAvailablePort(preferredPort: number): Promise<number> {
|
||||||
|
let port = Math.max(1, Math.trunc(preferredPort));
|
||||||
|
while (!(await isPortAvailable(port))) {
|
||||||
|
port += 1;
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPidFilePort(postmasterPidFile: string): number | null {
|
||||||
|
if (!fs.existsSync(postmasterPidFile)) return null;
|
||||||
|
try {
|
||||||
|
const lines = fs.readFileSync(postmasterPidFile, "utf8").split("\n");
|
||||||
|
const port = Number(lines[3]?.trim());
|
||||||
|
return Number.isInteger(port) && port > 0 ? port : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
|
||||||
|
if (!fs.existsSync(postmasterPidFile)) return null;
|
||||||
|
try {
|
||||||
|
const pid = Number(fs.readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return pid;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
|
||||||
|
const moduleName = "embedded-postgres";
|
||||||
|
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
||||||
|
try {
|
||||||
|
const mod = await import(moduleName);
|
||||||
|
EmbeddedPostgres = mod.default as EmbeddedPostgresCtor;
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||||
|
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||||
|
if (runningPid) {
|
||||||
|
return {
|
||||||
|
port: readPidFilePort(postmasterPidFile) ?? preferredPort,
|
||||||
|
startedByThisProcess: false,
|
||||||
|
stop: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = await findAvailablePort(preferredPort);
|
||||||
|
const logBuffer = createEmbeddedPostgresLogBuffer();
|
||||||
|
const instance = new EmbeddedPostgres({
|
||||||
|
databaseDir: dataDir,
|
||||||
|
user: "paperclip",
|
||||||
|
password: "paperclip",
|
||||||
|
port,
|
||||||
|
persistent: true,
|
||||||
|
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||||
|
onLog: logBuffer.append,
|
||||||
|
onError: logBuffer.append,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||||
|
try {
|
||||||
|
await instance.initialise();
|
||||||
|
} catch (error) {
|
||||||
|
throw formatEmbeddedPostgresError(error, {
|
||||||
|
fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`,
|
||||||
|
recentLogs: logBuffer.getRecentLogs(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(postmasterPidFile)) {
|
||||||
|
fs.rmSync(postmasterPidFile, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await instance.start();
|
||||||
|
} catch (error) {
|
||||||
|
throw formatEmbeddedPostgresError(error, {
|
||||||
|
fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`,
|
||||||
|
recentLogs: logBuffer.getRecentLogs(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
startedByThisProcess: true,
|
||||||
|
stop: async () => {
|
||||||
|
await instance.stop();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeDb(db: ClosableDb): Promise<void> {
|
||||||
|
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfiguredDb(configPath: string): Promise<{
|
||||||
|
db: ClosableDb;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const config = readConfig(configPath);
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`Config not found at ${configPath}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let embeddedHandle: EmbeddedPostgresHandle | null = null;
|
||||||
|
try {
|
||||||
|
if (config.database.mode === "embedded-postgres") {
|
||||||
|
embeddedHandle = await ensureEmbeddedPostgres(
|
||||||
|
config.database.embeddedPostgresDataDir,
|
||||||
|
config.database.embeddedPostgresPort,
|
||||||
|
);
|
||||||
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||||
|
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/paperclip`;
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
const db = createDb(connectionString) as ClosableDb;
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
stop: async () => {
|
||||||
|
await closeDb(db);
|
||||||
|
if (embeddedHandle?.startedByThisProcess) {
|
||||||
|
await embeddedHandle.stop().catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionString = nonEmpty(config.database.connectionString);
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error(`Config at ${configPath} does not define a database connection string.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
const db = createDb(connectionString) as ClosableDb;
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
stop: async () => {
|
||||||
|
await closeDb(db);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (embeddedHandle?.startedByThisProcess) {
|
||||||
|
await embeddedHandle.stop().catch(() => undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableAllRoutinesInConfig(
|
||||||
|
options: Pick<RoutinesDisableAllOptions, "config" | "companyId">,
|
||||||
|
): Promise<DisableAllRoutinesResult> {
|
||||||
|
const configPath = resolveConfigPath(options.config);
|
||||||
|
loadPaperclipEnvFile(configPath);
|
||||||
|
const companyId =
|
||||||
|
nonEmpty(options.companyId)
|
||||||
|
?? nonEmpty(process.env.PAPERCLIP_COMPANY_ID)
|
||||||
|
?? null;
|
||||||
|
if (!companyId) {
|
||||||
|
throw new Error("Company ID is required. Pass --company-id or set PAPERCLIP_COMPANY_ID.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = readConfig(configPath);
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`Config not found at ${configPath}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let embeddedHandle: EmbeddedPostgresHandle | null = null;
|
||||||
|
let db: ClosableDb | null = null;
|
||||||
|
try {
|
||||||
|
if (config.database.mode === "embedded-postgres") {
|
||||||
|
embeddedHandle = await ensureEmbeddedPostgres(
|
||||||
|
config.database.embeddedPostgresDataDir,
|
||||||
|
config.database.embeddedPostgresPort,
|
||||||
|
);
|
||||||
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||||
|
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/paperclip`;
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
db = createDb(connectionString) as ClosableDb;
|
||||||
|
} else {
|
||||||
|
const connectionString = nonEmpty(config.database.connectionString);
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error(`Config at ${configPath} does not define a database connection string.`);
|
||||||
|
}
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
db = createDb(connectionString) as ClosableDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({
|
||||||
|
id: routines.id,
|
||||||
|
status: routines.status,
|
||||||
|
})
|
||||||
|
.from(routines)
|
||||||
|
.where(eq(routines.companyId, companyId));
|
||||||
|
|
||||||
|
const alreadyPausedCount = existing.filter((routine) => routine.status === "paused").length;
|
||||||
|
const archivedCount = existing.filter((routine) => routine.status === "archived").length;
|
||||||
|
const idsToPause = existing
|
||||||
|
.filter((routine) => routine.status !== "paused" && routine.status !== "archived")
|
||||||
|
.map((routine) => routine.id);
|
||||||
|
|
||||||
|
if (idsToPause.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(routines)
|
||||||
|
.set({
|
||||||
|
status: "paused",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(inArray(routines.id, idsToPause));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyId,
|
||||||
|
totalRoutines: existing.length,
|
||||||
|
pausedCount: idsToPause.length,
|
||||||
|
alreadyPausedCount,
|
||||||
|
archivedCount,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (db) {
|
||||||
|
await closeDb(db);
|
||||||
|
}
|
||||||
|
if (embeddedHandle?.startedByThisProcess) {
|
||||||
|
await embeddedHandle.stop().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableAllRoutinesCommand(options: RoutinesDisableAllOptions): Promise<void> {
|
||||||
|
const result = await disableAllRoutinesInConfig(options);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.totalRoutines === 0) {
|
||||||
|
console.log(pc.dim(`No routines found for company ${result.companyId}.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Paused ${result.pausedCount} routine(s) for company ${result.companyId} ` +
|
||||||
|
`(${result.alreadyPausedCount} already paused, ${result.archivedCount} archived).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerRoutineCommands(program: Command): void {
|
||||||
|
const routinesCommand = program.command("routines").description("Local routine maintenance commands");
|
||||||
|
|
||||||
|
routinesCommand
|
||||||
|
.command("disable-all")
|
||||||
|
.description("Pause all non-archived routines in the configured local instance for one company")
|
||||||
|
.option("-c, --config <path>", "Path to config file")
|
||||||
|
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
|
||||||
|
.option("-C, --company-id <id>", "Company ID")
|
||||||
|
.option("--json", "Output raw JSON")
|
||||||
|
.action(async (opts: RoutinesDisableAllOptions) => {
|
||||||
|
try {
|
||||||
|
await disableAllRoutinesCommand(opts);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(pc.red(message));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { registerAgentCommands } from "./commands/client/agent.js";
|
|||||||
import { registerApprovalCommands } from "./commands/client/approval.js";
|
import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||||
|
import { registerRoutineCommands } from "./commands/routines.js";
|
||||||
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
||||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||||
@@ -141,6 +142,7 @@ registerAgentCommands(program);
|
|||||||
registerApprovalCommands(program);
|
registerApprovalCommands(program);
|
||||||
registerActivityCommands(program);
|
registerActivityCommands(program);
|
||||||
registerDashboardCommands(program);
|
registerDashboardCommands(program);
|
||||||
|
registerRoutineCommands(program);
|
||||||
registerFeedbackCommands(program);
|
registerFeedbackCommands(program);
|
||||||
registerWorktreeCommands(program);
|
registerWorktreeCommands(program);
|
||||||
registerPluginCommands(program);
|
registerPluginCommands(program);
|
||||||
|
|||||||
@@ -35,11 +35,12 @@
|
|||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && cp -r src/migrations dist/migrations",
|
"check:migrations": "tsx src/check-migration-numbering.ts",
|
||||||
|
"build": "pnpm run check:migrations && tsc && cp -r src/migrations dist/migrations",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "pnpm run check:migrations && tsc --noEmit",
|
||||||
"generate": "tsc -p tsconfig.json && drizzle-kit generate",
|
"generate": "pnpm run check:migrations && tsc -p tsconfig.json && drizzle-kit generate",
|
||||||
"migrate": "tsx src/migrate.ts",
|
"migrate": "pnpm run check:migrations && tsx src/migrate.ts",
|
||||||
"seed": "tsx src/seed.ts"
|
"seed": "tsx src/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
89
packages/db/src/check-migration-numbering.ts
Normal file
89
packages/db/src/check-migration-numbering.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const migrationsDir = fileURLToPath(new URL("./migrations", import.meta.url));
|
||||||
|
const journalPath = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url));
|
||||||
|
|
||||||
|
type JournalFile = {
|
||||||
|
entries?: Array<{
|
||||||
|
idx?: number;
|
||||||
|
tag?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function migrationNumber(value: string): string | null {
|
||||||
|
const match = value.match(/^(\d{4})_/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNoDuplicates(values: string[], label: string) {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const number = migrationNumber(value);
|
||||||
|
if (!number) {
|
||||||
|
throw new Error(`${label} entry does not start with a 4-digit migration number: ${value}`);
|
||||||
|
}
|
||||||
|
const existing = seen.get(number);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Duplicate migration number ${number} in ${label}: ${existing}, ${value}`);
|
||||||
|
}
|
||||||
|
seen.set(number, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStrictlyOrdered(values: string[], label: string) {
|
||||||
|
const sorted = [...values].sort();
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
if (values[index] !== sorted[index]) {
|
||||||
|
throw new Error(
|
||||||
|
`${label} are out of order at position ${index}: expected ${sorted[index]}, found ${values[index]}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureJournalMatchesFiles(migrationFiles: string[], journalTags: string[]) {
|
||||||
|
const journalFiles = journalTags.map((tag) => `${tag}.sql`);
|
||||||
|
|
||||||
|
if (journalFiles.length !== migrationFiles.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Migration journal/file count mismatch: journal has ${journalFiles.length}, files have ${migrationFiles.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < migrationFiles.length; index += 1) {
|
||||||
|
const migrationFile = migrationFiles[index];
|
||||||
|
const journalFile = journalFiles[index];
|
||||||
|
if (migrationFile !== journalFile) {
|
||||||
|
throw new Error(
|
||||||
|
`Migration journal/file order mismatch at position ${index}: journal has ${journalFile}, files have ${migrationFile}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const migrationFiles = (await readdir(migrationsDir))
|
||||||
|
.filter((entry) => entry.endsWith(".sql"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
ensureNoDuplicates(migrationFiles, "migration files");
|
||||||
|
ensureStrictlyOrdered(migrationFiles, "migration files");
|
||||||
|
|
||||||
|
const rawJournal = await readFile(journalPath, "utf8");
|
||||||
|
const journal = JSON.parse(rawJournal) as JournalFile;
|
||||||
|
const journalTags = (journal.entries ?? [])
|
||||||
|
.map((entry, index) => {
|
||||||
|
if (typeof entry.tag !== "string" || entry.tag.length === 0) {
|
||||||
|
throw new Error(`Migration journal entry ${index} is missing a tag`);
|
||||||
|
}
|
||||||
|
return entry.tag;
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureNoDuplicates(journalTags, "migration journal");
|
||||||
|
ensureStrictlyOrdered(journalTags, "migration journal");
|
||||||
|
ensureJournalMatchesFiles(migrationFiles, journalTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -305,6 +305,99 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
|||||||
|
|
||||||
const finalState = await inspectMigrations(connectionString);
|
const finalState = await inspectMigrations(connectionString);
|
||||||
expect(finalState.status).toBe("upToDate");
|
expect(finalState.status).toBe("upToDate");
|
||||||
|
|
||||||
|
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
const constraints = await verifySql.unsafe<{ conname: string }[]>(
|
||||||
|
`
|
||||||
|
SELECT conname
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname IN (
|
||||||
|
'feedback_exports_company_id_companies_id_fk',
|
||||||
|
'feedback_exports_feedback_vote_id_feedback_votes_id_fk',
|
||||||
|
'feedback_exports_issue_id_issues_id_fk',
|
||||||
|
'feedback_votes_company_id_companies_id_fk',
|
||||||
|
'feedback_votes_issue_id_issues_id_fk'
|
||||||
|
)
|
||||||
|
ORDER BY conname
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
expect(constraints.map((row) => row.conname)).toEqual([
|
||||||
|
"feedback_exports_company_id_companies_id_fk",
|
||||||
|
"feedback_exports_feedback_vote_id_feedback_votes_id_fk",
|
||||||
|
"feedback_exports_issue_id_issues_id_fk",
|
||||||
|
"feedback_votes_company_id_companies_id_fk",
|
||||||
|
"feedback_votes_issue_id_issues_id_fk",
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await verifySql.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"replays migration 0048 safely when routines.variables already exists",
|
||||||
|
async () => {
|
||||||
|
const connectionString = await createTempDatabase();
|
||||||
|
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
|
||||||
|
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
const flashyMarrowHash = await migrationHash("0048_flashy_marrow.sql");
|
||||||
|
|
||||||
|
await sql.unsafe(
|
||||||
|
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${flashyMarrowHash}'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = await sql.unsafe<{ column_name: string }[]>(
|
||||||
|
`
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'routines'
|
||||||
|
AND column_name = 'variables'
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
expect(columns).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingState = await inspectMigrations(connectionString);
|
||||||
|
expect(pendingState).toMatchObject({
|
||||||
|
status: "needsMigrations",
|
||||||
|
pendingMigrations: ["0048_flashy_marrow.sql"],
|
||||||
|
reason: "pending-migrations",
|
||||||
|
});
|
||||||
|
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
|
||||||
|
const finalState = await inspectMigrations(connectionString);
|
||||||
|
expect(finalState.status).toBe("upToDate");
|
||||||
|
|
||||||
|
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; data_type: string }[]>(
|
||||||
|
`
|
||||||
|
SELECT column_name, is_nullable, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'routines'
|
||||||
|
AND column_name = 'variables'
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
expect(columns).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
column_name: "variables",
|
||||||
|
is_nullable: "NO",
|
||||||
|
data_type: "jsonb",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await verifySql.end();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
20_000,
|
20_000,
|
||||||
);
|
);
|
||||||
|
|||||||
1
packages/db/src/migrations/0048_flashy_marrow.sql
Normal file
1
packages/db/src/migrations/0048_flashy_marrow.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "variables" jsonb DEFAULT '[]'::jsonb NOT NULL;
|
||||||
12546
packages/db/src/migrations/meta/0048_snapshot.json
Normal file
12546
packages/db/src/migrations/meta/0048_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -337,6 +337,13 @@
|
|||||||
"when": 1775137972687,
|
"when": 1775137972687,
|
||||||
"tag": "0047_overjoyed_groot",
|
"tag": "0047_overjoyed_groot",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 48,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775145655557,
|
||||||
|
"tag": "0048_flashy_marrow",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import { companySecrets } from "./company_secrets.js";
|
|||||||
import { issues } from "./issues.js";
|
import { issues } from "./issues.js";
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
import { goals } from "./goals.js";
|
import { goals } from "./goals.js";
|
||||||
|
import type { RoutineVariable } from "@paperclipai/shared";
|
||||||
|
|
||||||
export const routines = pgTable(
|
export const routines = pgTable(
|
||||||
"routines",
|
"routines",
|
||||||
@@ -31,6 +32,7 @@ export const routines = pgTable(
|
|||||||
status: text("status").notNull().default("active"),
|
status: text("status").notNull().default("active"),
|
||||||
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
|
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
|
||||||
catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"),
|
catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"),
|
||||||
|
variables: jsonb("variables").$type<RoutineVariable[]>().notNull().default([]),
|
||||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
createdByUserId: text("created_by_user_id"),
|
createdByUserId: text("created_by_user_id"),
|
||||||
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ export type RoutineTriggerKind = (typeof ROUTINE_TRIGGER_KINDS)[number];
|
|||||||
export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256"] as const;
|
export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256"] as const;
|
||||||
export type RoutineTriggerSigningMode = (typeof ROUTINE_TRIGGER_SIGNING_MODES)[number];
|
export type RoutineTriggerSigningMode = (typeof ROUTINE_TRIGGER_SIGNING_MODES)[number];
|
||||||
|
|
||||||
|
export const ROUTINE_VARIABLE_TYPES = ["text", "textarea", "number", "boolean", "select"] as const;
|
||||||
|
export type RoutineVariableType = (typeof ROUTINE_VARIABLE_TYPES)[number];
|
||||||
|
|
||||||
export const ROUTINE_RUN_STATUSES = [
|
export const ROUTINE_RUN_STATUSES = [
|
||||||
"received",
|
"received",
|
||||||
"coalesced",
|
"coalesced",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export {
|
|||||||
ROUTINE_CATCH_UP_POLICIES,
|
ROUTINE_CATCH_UP_POLICIES,
|
||||||
ROUTINE_TRIGGER_KINDS,
|
ROUTINE_TRIGGER_KINDS,
|
||||||
ROUTINE_TRIGGER_SIGNING_MODES,
|
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||||
|
ROUTINE_VARIABLE_TYPES,
|
||||||
ROUTINE_RUN_STATUSES,
|
ROUTINE_RUN_STATUSES,
|
||||||
ROUTINE_RUN_SOURCES,
|
ROUTINE_RUN_SOURCES,
|
||||||
PAUSE_REASONS,
|
PAUSE_REASONS,
|
||||||
@@ -88,6 +89,7 @@ export {
|
|||||||
type RoutineCatchUpPolicy,
|
type RoutineCatchUpPolicy,
|
||||||
type RoutineTriggerKind,
|
type RoutineTriggerKind,
|
||||||
type RoutineTriggerSigningMode,
|
type RoutineTriggerSigningMode,
|
||||||
|
type RoutineVariableType,
|
||||||
type RoutineRunStatus,
|
type RoutineRunStatus,
|
||||||
type RoutineRunSource,
|
type RoutineRunSource,
|
||||||
type PauseReason,
|
type PauseReason,
|
||||||
@@ -255,6 +257,8 @@ export type {
|
|||||||
FinanceSummary,
|
FinanceSummary,
|
||||||
FinanceByBiller,
|
FinanceByBiller,
|
||||||
FinanceByKind,
|
FinanceByKind,
|
||||||
|
AgentWakeupResponse,
|
||||||
|
AgentWakeupSkipped,
|
||||||
HeartbeatRun,
|
HeartbeatRun,
|
||||||
HeartbeatRunEvent,
|
HeartbeatRunEvent,
|
||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
@@ -304,6 +308,8 @@ export type {
|
|||||||
CompanySecret,
|
CompanySecret,
|
||||||
SecretProviderDescriptor,
|
SecretProviderDescriptor,
|
||||||
Routine,
|
Routine,
|
||||||
|
RoutineVariable,
|
||||||
|
RoutineVariableDefaultValue,
|
||||||
RoutineTrigger,
|
RoutineTrigger,
|
||||||
RoutineRun,
|
RoutineRun,
|
||||||
RoutineTriggerSecretMaterial,
|
RoutineTriggerSecretMaterial,
|
||||||
@@ -473,6 +479,7 @@ export {
|
|||||||
updateRoutineSchema,
|
updateRoutineSchema,
|
||||||
createRoutineTriggerSchema,
|
createRoutineTriggerSchema,
|
||||||
updateRoutineTriggerSchema,
|
updateRoutineTriggerSchema,
|
||||||
|
routineVariableSchema,
|
||||||
runRoutineSchema,
|
runRoutineSchema,
|
||||||
rotateRoutineTriggerSecretSchema,
|
rotateRoutineTriggerSecretSchema,
|
||||||
type CreateSecret,
|
type CreateSecret,
|
||||||
@@ -597,6 +604,14 @@ export {
|
|||||||
type ParsedProjectMention,
|
type ParsedProjectMention,
|
||||||
} from "./project-mentions.js";
|
} from "./project-mentions.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
extractRoutineVariableNames,
|
||||||
|
interpolateRoutineTemplate,
|
||||||
|
isValidRoutineVariableName,
|
||||||
|
stringifyRoutineVariableValue,
|
||||||
|
syncRoutineVariablesWithTemplate,
|
||||||
|
} from "./routine-variables.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
paperclipConfigSchema,
|
paperclipConfigSchema,
|
||||||
configMetaSchema,
|
configMetaSchema,
|
||||||
|
|||||||
34
packages/shared/src/routine-variables.test.ts
Normal file
34
packages/shared/src/routine-variables.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
extractRoutineVariableNames,
|
||||||
|
interpolateRoutineTemplate,
|
||||||
|
syncRoutineVariablesWithTemplate,
|
||||||
|
} from "./routine-variables.js";
|
||||||
|
|
||||||
|
describe("routine variable helpers", () => {
|
||||||
|
it("extracts placeholder names in first-appearance order", () => {
|
||||||
|
expect(
|
||||||
|
extractRoutineVariableNames("Review {{repo}} and {{priority}} for {{repo}}"),
|
||||||
|
).toEqual(["repo", "priority"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing metadata when syncing variables from a template", () => {
|
||||||
|
expect(
|
||||||
|
syncRoutineVariablesWithTemplate("Review {{repo}} and {{priority}}", [
|
||||||
|
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
|
||||||
|
{ name: "priority", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates provided variable values into the routine template", () => {
|
||||||
|
expect(
|
||||||
|
interpolateRoutineTemplate("Review {{repo}} for {{priority}}", {
|
||||||
|
repo: "paperclip",
|
||||||
|
priority: "high",
|
||||||
|
}),
|
||||||
|
).toBe("Review paperclip for high");
|
||||||
|
});
|
||||||
|
});
|
||||||
62
packages/shared/src/routine-variables.ts
Normal file
62
packages/shared/src/routine-variables.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { RoutineVariable } from "./types/routine.js";
|
||||||
|
|
||||||
|
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
|
||||||
|
|
||||||
|
export function isValidRoutineVariableName(name: string): boolean {
|
||||||
|
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRoutineVariableNames(template: string | null | undefined): string[] {
|
||||||
|
if (!template) return [];
|
||||||
|
const found = new Set<string>();
|
||||||
|
for (const match of template.matchAll(ROUTINE_VARIABLE_MATCHER)) {
|
||||||
|
const name = match[1];
|
||||||
|
if (name && !found.has(name)) {
|
||||||
|
found.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...found];
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRoutineVariable(name: string): RoutineVariable {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
label: null,
|
||||||
|
type: "text",
|
||||||
|
defaultValue: null,
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncRoutineVariablesWithTemplate(
|
||||||
|
template: string | null | undefined,
|
||||||
|
existing: RoutineVariable[] | null | undefined,
|
||||||
|
): RoutineVariable[] {
|
||||||
|
const names = extractRoutineVariableNames(template);
|
||||||
|
const existingByName = new Map((existing ?? []).map((variable) => [variable.name, variable]));
|
||||||
|
return names.map((name) => existingByName.get(name) ?? defaultRoutineVariable(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifyRoutineVariableValue(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
if (value == null) return "";
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interpolateRoutineTemplate(
|
||||||
|
template: string | null | undefined,
|
||||||
|
values: Record<string, unknown> | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (template == null) return null;
|
||||||
|
if (!values || Object.keys(values).length === 0) return template;
|
||||||
|
return template.replace(ROUTINE_VARIABLE_MATCHER, (match, rawName: string) => {
|
||||||
|
if (!(rawName in values)) return match;
|
||||||
|
return stringifyRoutineVariableValue(values[rawName]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ export interface CompanyPortabilityProjectManifestEntry {
|
|||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { RoutineVariable } from "./routine.js";
|
||||||
|
|
||||||
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
|
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -84,6 +86,7 @@ export interface CompanyPortabilityIssueRoutineTriggerManifestEntry {
|
|||||||
export interface CompanyPortabilityIssueRoutineManifestEntry {
|
export interface CompanyPortabilityIssueRoutineManifestEntry {
|
||||||
concurrencyPolicy: string | null;
|
concurrencyPolicy: string | null;
|
||||||
catchUpPolicy: string | null;
|
catchUpPolicy: string | null;
|
||||||
|
variables?: RoutineVariable[] | null;
|
||||||
triggers: CompanyPortabilityIssueRoutineTriggerManifestEntry[];
|
triggers: CompanyPortabilityIssueRoutineTriggerManifestEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ export interface HeartbeatRun {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentWakeupSkipped {
|
||||||
|
status: "skipped";
|
||||||
|
reason: string;
|
||||||
|
message: string | null;
|
||||||
|
issueId: string | null;
|
||||||
|
executionRunId: string | null;
|
||||||
|
executionAgentId: string | null;
|
||||||
|
executionAgentName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentWakeupResponse = HeartbeatRun | AgentWakeupSkipped;
|
||||||
|
|
||||||
export interface HeartbeatRunEvent {
|
export interface HeartbeatRunEvent {
|
||||||
id: number;
|
id: number;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export type {
|
|||||||
} from "./secrets.js";
|
} from "./secrets.js";
|
||||||
export type {
|
export type {
|
||||||
Routine,
|
Routine,
|
||||||
|
RoutineVariable,
|
||||||
|
RoutineVariableDefaultValue,
|
||||||
RoutineTrigger,
|
RoutineTrigger,
|
||||||
RoutineRun,
|
RoutineRun,
|
||||||
RoutineTriggerSecretMaterial,
|
RoutineTriggerSecretMaterial,
|
||||||
@@ -141,6 +143,8 @@ export type {
|
|||||||
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js";
|
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js";
|
||||||
export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
|
export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
|
||||||
export type {
|
export type {
|
||||||
|
AgentWakeupResponse,
|
||||||
|
AgentWakeupSkipped,
|
||||||
HeartbeatRun,
|
HeartbeatRun,
|
||||||
HeartbeatRunEvent,
|
HeartbeatRunEvent,
|
||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IssueOriginKind } from "../constants.js";
|
import type { IssueOriginKind, RoutineVariableType } from "../constants.js";
|
||||||
|
|
||||||
export interface RoutineProjectSummary {
|
export interface RoutineProjectSummary {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +25,17 @@ export interface RoutineIssueSummary {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RoutineVariableDefaultValue = string | number | boolean | null;
|
||||||
|
|
||||||
|
export interface RoutineVariable {
|
||||||
|
name: string;
|
||||||
|
label: string | null;
|
||||||
|
type: RoutineVariableType;
|
||||||
|
defaultValue: RoutineVariableDefaultValue;
|
||||||
|
required: boolean;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Routine {
|
export interface Routine {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -38,6 +49,7 @@ export interface Routine {
|
|||||||
status: string;
|
status: string;
|
||||||
concurrencyPolicy: string;
|
concurrencyPolicy: string;
|
||||||
catchUpPolicy: string;
|
catchUpPolicy: string;
|
||||||
|
variables: RoutineVariable[];
|
||||||
createdByAgentId: string | null;
|
createdByAgentId: string | null;
|
||||||
createdByUserId: string | null;
|
createdByUserId: string | null;
|
||||||
updatedByAgentId: string | null;
|
updatedByAgentId: string | null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { routineVariableSchema } from "./routine.js";
|
||||||
|
|
||||||
export const portabilityIncludeSchema = z
|
export const portabilityIncludeSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -123,6 +124,7 @@ export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({
|
|||||||
export const portabilityIssueRoutineManifestEntrySchema = z.object({
|
export const portabilityIssueRoutineManifestEntrySchema = z.object({
|
||||||
concurrencyPolicy: z.string().nullable(),
|
concurrencyPolicy: z.string().nullable(),
|
||||||
catchUpPolicy: z.string().nullable(),
|
catchUpPolicy: z.string().nullable(),
|
||||||
|
variables: z.array(routineVariableSchema).nullable().optional(),
|
||||||
triggers: z.array(portabilityIssueRoutineTriggerManifestEntrySchema).default([]),
|
triggers: z.array(portabilityIssueRoutineTriggerManifestEntrySchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export {
|
|||||||
updateRoutineSchema,
|
updateRoutineSchema,
|
||||||
createRoutineTriggerSchema,
|
createRoutineTriggerSchema,
|
||||||
updateRoutineTriggerSchema,
|
updateRoutineTriggerSchema,
|
||||||
|
routineVariableSchema,
|
||||||
runRoutineSchema,
|
runRoutineSchema,
|
||||||
rotateRoutineTriggerSecretSchema,
|
rotateRoutineTriggerSecretSchema,
|
||||||
type CreateRoutine,
|
type CreateRoutine,
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
|
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
|
||||||
|
|
||||||
|
export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [
|
||||||
|
"inherit",
|
||||||
|
"shared_workspace",
|
||||||
|
"isolated_workspace",
|
||||||
|
"operator_branch",
|
||||||
|
"reuse_existing",
|
||||||
|
"agent_default",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const executionWorkspaceStrategySchema = z
|
const executionWorkspaceStrategySchema = z
|
||||||
.object({
|
.object({
|
||||||
type: z.enum(["project_primary", "git_worktree", "adapter_managed", "cloud_sandbox"]).optional(),
|
type: z.enum(["project_primary", "git_worktree", "adapter_managed", "cloud_sandbox"]).optional(),
|
||||||
@@ -14,7 +23,7 @@ const executionWorkspaceStrategySchema = z
|
|||||||
|
|
||||||
export const issueExecutionWorkspaceSettingsSchema = z
|
export const issueExecutionWorkspaceSettingsSchema = z
|
||||||
.object({
|
.object({
|
||||||
mode: z.enum(["inherit", "shared_workspace", "isolated_workspace", "operator_branch", "reuse_existing", "agent_default"]).optional(),
|
mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(),
|
||||||
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
||||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||||
})
|
})
|
||||||
@@ -43,14 +52,7 @@ export const createIssueSchema = z.object({
|
|||||||
billingCode: z.string().optional().nullable(),
|
billingCode: z.string().optional().nullable(),
|
||||||
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
||||||
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
||||||
executionWorkspacePreference: z.enum([
|
executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(),
|
||||||
"inherit",
|
|
||||||
"shared_workspace",
|
|
||||||
"isolated_workspace",
|
|
||||||
"operator_branch",
|
|
||||||
"reuse_existing",
|
|
||||||
"agent_default",
|
|
||||||
]).optional().nullable(),
|
|
||||||
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
|
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
|
||||||
labelIds: z.array(z.string().uuid()).optional(),
|
labelIds: z.array(z.string().uuid()).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,47 @@ import {
|
|||||||
ROUTINE_CONCURRENCY_POLICIES,
|
ROUTINE_CONCURRENCY_POLICIES,
|
||||||
ROUTINE_STATUSES,
|
ROUTINE_STATUSES,
|
||||||
ROUTINE_TRIGGER_SIGNING_MODES,
|
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||||
|
ROUTINE_VARIABLE_TYPES,
|
||||||
} from "../constants.js";
|
} from "../constants.js";
|
||||||
|
import {
|
||||||
|
ISSUE_EXECUTION_WORKSPACE_PREFERENCES,
|
||||||
|
issueExecutionWorkspaceSettingsSchema,
|
||||||
|
} from "./issue.js";
|
||||||
|
|
||||||
|
const routineVariableValueSchema = z.union([z.string(), z.number().finite(), z.boolean()]);
|
||||||
|
|
||||||
|
export const routineVariableSchema = z.object({
|
||||||
|
name: z.string().trim().regex(/^[A-Za-z][A-Za-z0-9_]*$/),
|
||||||
|
label: z.string().trim().max(120).optional().nullable(),
|
||||||
|
type: z.enum(ROUTINE_VARIABLE_TYPES).optional().default("text"),
|
||||||
|
defaultValue: routineVariableValueSchema.optional().nullable(),
|
||||||
|
required: z.boolean().optional().default(true),
|
||||||
|
options: z.array(z.string().trim().min(1).max(120)).max(50).optional().default([]),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (value.type === "select" && value.options.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["options"],
|
||||||
|
message: "Select variables require at least one option",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value.type !== "select" && value.options.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["options"],
|
||||||
|
message: "Only select variables can define options",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value.type === "select" && value.defaultValue != null) {
|
||||||
|
if (typeof value.defaultValue !== "string" || !value.options.includes(value.defaultValue)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["defaultValue"],
|
||||||
|
message: "Select variable defaults must match one of the allowed options",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const createRoutineSchema = z.object({
|
export const createRoutineSchema = z.object({
|
||||||
projectId: z.string().uuid(),
|
projectId: z.string().uuid(),
|
||||||
@@ -18,6 +58,7 @@ export const createRoutineSchema = z.object({
|
|||||||
status: z.enum(ROUTINE_STATUSES).optional().default("active"),
|
status: z.enum(ROUTINE_STATUSES).optional().default("active"),
|
||||||
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"),
|
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"),
|
||||||
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional().default("skip_missed"),
|
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional().default("skip_missed"),
|
||||||
|
variables: z.array(routineVariableSchema).optional().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateRoutine = z.infer<typeof createRoutineSchema>;
|
export type CreateRoutine = z.infer<typeof createRoutineSchema>;
|
||||||
@@ -62,8 +103,12 @@ export type UpdateRoutineTrigger = z.infer<typeof updateRoutineTriggerSchema>;
|
|||||||
export const runRoutineSchema = z.object({
|
export const runRoutineSchema = z.object({
|
||||||
triggerId: z.string().uuid().optional().nullable(),
|
triggerId: z.string().uuid().optional().nullable(),
|
||||||
payload: z.record(z.unknown()).optional().nullable(),
|
payload: z.record(z.unknown()).optional().nullable(),
|
||||||
|
variables: z.record(routineVariableValueSchema).optional().nullable(),
|
||||||
idempotencyKey: z.string().trim().max(255).optional().nullable(),
|
idempotencyKey: z.string().trim().max(255).optional().nullable(),
|
||||||
source: z.enum(["manual", "api"]).optional().default("manual"),
|
source: z.enum(["manual", "api"]).optional().default("manual"),
|
||||||
|
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
||||||
|
executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(),
|
||||||
|
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RunRoutine = z.infer<typeof runRoutineSchema>;
|
export type RunRoutine = z.infer<typeof runRoutineSchema>;
|
||||||
|
|||||||
@@ -31,20 +31,41 @@ source_env_path="$(dirname "$source_config_path")/.env"
|
|||||||
|
|
||||||
mkdir -p "$paperclip_dir"
|
mkdir -p "$paperclip_dir"
|
||||||
|
|
||||||
run_isolated_worktree_init() {
|
run_paperclipai_command() {
|
||||||
|
local command_args=("$@")
|
||||||
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
|
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
|
||||||
pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
pnpm paperclipai "${command_args[@]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local base_cli_tsx_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
|
||||||
|
local base_cli_entry_path="$base_cwd/cli/src/index.ts"
|
||||||
|
if command -v node >/dev/null 2>&1 && [[ -f "$base_cli_tsx_path" ]] && [[ -f "$base_cli_entry_path" ]]; then
|
||||||
|
node "$base_cli_tsx_path" "$base_cli_entry_path" "${command_args[@]}"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v paperclipai >/dev/null 2>&1; then
|
if command -v paperclipai >/dev/null 2>&1; then
|
||||||
paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
paperclipai "${command_args[@]}"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
run_isolated_worktree_init() {
|
||||||
|
run_paperclipai_command \
|
||||||
|
worktree \
|
||||||
|
init \
|
||||||
|
--force \
|
||||||
|
--seed-mode \
|
||||||
|
minimal \
|
||||||
|
--name \
|
||||||
|
"$worktree_name" \
|
||||||
|
--from-config \
|
||||||
|
"$source_config_path"
|
||||||
|
}
|
||||||
|
|
||||||
write_fallback_worktree_config() {
|
write_fallback_worktree_config() {
|
||||||
WORKTREE_NAME="$worktree_name" \
|
WORKTREE_NAME="$worktree_name" \
|
||||||
BASE_CWD="$base_cwd" \
|
BASE_CWD="$base_cwd" \
|
||||||
@@ -300,6 +321,20 @@ if ! run_isolated_worktree_init; then
|
|||||||
write_fallback_worktree_config
|
write_fallback_worktree_config
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
disable_seeded_routines() {
|
||||||
|
local company_id="${PAPERCLIP_COMPANY_ID:-}"
|
||||||
|
if [[ -z "$company_id" ]]; then
|
||||||
|
echo "PAPERCLIP_COMPANY_ID not set; skipping routine disable post-step." >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! run_paperclipai_command routines disable-all --config "$worktree_config_path" --company-id "$company_id"; then
|
||||||
|
echo "paperclipai CLI not available in this workspace; skipping routine disable post-step." >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_seeded_routines
|
||||||
|
|
||||||
while IFS= read -r relative_path; do
|
while IFS= read -r relative_path; do
|
||||||
[[ -n "$relative_path" ]] || continue
|
[[ -n "$relative_path" ]] || continue
|
||||||
source_path="$base_cwd/$relative_path"
|
source_path="$base_cwd/$relative_path"
|
||||||
|
|||||||
@@ -123,11 +123,14 @@ function setVersion(version) {
|
|||||||
`.version("${version}")`,
|
`.version("${version}")`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cliEntry === nextCliEntry) {
|
if (cliEntry !== nextCliEntry) {
|
||||||
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
|
writeFileSync(cliEntryPath, nextCliEntry);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(cliEntryPath, nextCliEntry);
|
if (!cliEntry.includes(".version(cliVersion)")) {
|
||||||
|
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPackages() {
|
function listPackages() {
|
||||||
|
|||||||
@@ -231,11 +231,31 @@ describe("agent skill routes", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps runtime materialization for persistent skill adapters", async () => {
|
it("skips runtime materialization when listing Codex skills", async () => {
|
||||||
mockAgentService.getById.mockResolvedValue(makeAgent("codex_local"));
|
mockAgentService.getById.mockResolvedValue(makeAgent("codex_local"));
|
||||||
mockAdapter.listSkills.mockResolvedValue({
|
mockAdapter.listSkills.mockResolvedValue({
|
||||||
adapterType: "codex_local",
|
adapterType: "codex_local",
|
||||||
supported: true,
|
supported: true,
|
||||||
|
mode: "ephemeral",
|
||||||
|
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||||
|
entries: [],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(createApp())
|
||||||
|
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||||
|
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||||
|
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
|
||||||
|
materializeMissing: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps runtime materialization for persistent skill adapters", async () => {
|
||||||
|
mockAgentService.getById.mockResolvedValue(makeAgent("cursor"));
|
||||||
|
mockAdapter.listSkills.mockResolvedValue({
|
||||||
|
adapterType: "cursor",
|
||||||
|
supported: true,
|
||||||
mode: "persistent",
|
mode: "persistent",
|
||||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||||
entries: [],
|
entries: [],
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe("instance settings routes", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
||||||
censorUsernameInLogs: false,
|
censorUsernameInLogs: false,
|
||||||
|
keyboardShortcuts: false,
|
||||||
feedbackDataSharingPreference: "prompt",
|
feedbackDataSharingPreference: "prompt",
|
||||||
});
|
});
|
||||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||||
@@ -45,6 +46,7 @@ describe("instance settings routes", () => {
|
|||||||
id: "instance-settings-1",
|
id: "instance-settings-1",
|
||||||
general: {
|
general: {
|
||||||
censorUsernameInLogs: true,
|
censorUsernameInLogs: true,
|
||||||
|
keyboardShortcuts: true,
|
||||||
feedbackDataSharingPreference: "allowed",
|
feedbackDataSharingPreference: "allowed",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -114,6 +116,7 @@ describe("instance settings routes", () => {
|
|||||||
expect(getRes.status).toBe(200);
|
expect(getRes.status).toBe(200);
|
||||||
expect(getRes.body).toEqual({
|
expect(getRes.body).toEqual({
|
||||||
censorUsernameInLogs: false,
|
censorUsernameInLogs: false,
|
||||||
|
keyboardShortcuts: false,
|
||||||
feedbackDataSharingPreference: "prompt",
|
feedbackDataSharingPreference: "prompt",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,18 +124,20 @@ describe("instance settings routes", () => {
|
|||||||
.patch("/api/instance/settings/general")
|
.patch("/api/instance/settings/general")
|
||||||
.send({
|
.send({
|
||||||
censorUsernameInLogs: true,
|
censorUsernameInLogs: true,
|
||||||
|
keyboardShortcuts: true,
|
||||||
feedbackDataSharingPreference: "allowed",
|
feedbackDataSharingPreference: "allowed",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(patchRes.status).toBe(200);
|
expect(patchRes.status).toBe(200);
|
||||||
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
|
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
|
||||||
censorUsernameInLogs: true,
|
censorUsernameInLogs: true,
|
||||||
|
keyboardShortcuts: true,
|
||||||
feedbackDataSharingPreference: "allowed",
|
feedbackDataSharingPreference: "allowed",
|
||||||
});
|
});
|
||||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-admin board users", async () => {
|
it("allows non-admin board users to read general settings", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
@@ -143,8 +148,25 @@ describe("instance settings routes", () => {
|
|||||||
|
|
||||||
const res = await request(app).get("/api/instance/settings/general");
|
const res = await request(app).get("/api/instance/settings/general");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockInstanceSettingsService.getGeneral).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-admin board users from updating general settings", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "user-1",
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/api/instance/settings/general")
|
||||||
|
.send({ censorUsernameInLogs: true, keyboardShortcuts: true });
|
||||||
|
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
|
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects agent callers", async () => {
|
it("rejects agent callers", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
@@ -428,6 +429,160 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||||||
resurfacedIssueId,
|
resurfacedIssueId,
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resurfaces archived issue when status/updatedAt changes after archiving", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const userId = "user-1";
|
||||||
|
const otherUserId = "user-2";
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issueId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(issues).values({
|
||||||
|
id: issueId,
|
||||||
|
companyId,
|
||||||
|
title: "Issue with old comment then status change",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
createdByUserId: userId,
|
||||||
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Old external comment before archiving
|
||||||
|
await db.insert(issueComments).values({
|
||||||
|
companyId,
|
||||||
|
issueId,
|
||||||
|
authorUserId: otherUserId,
|
||||||
|
body: "Old comment before archive",
|
||||||
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Archive after seeing the comment
|
||||||
|
await svc.archiveInbox(
|
||||||
|
companyId,
|
||||||
|
issueId,
|
||||||
|
userId,
|
||||||
|
new Date("2026-03-26T12:00:00.000Z"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify it's archived
|
||||||
|
const afterArchive = await svc.list(companyId, {
|
||||||
|
touchedByUserId: userId,
|
||||||
|
inboxArchivedByUserId: userId,
|
||||||
|
});
|
||||||
|
expect(afterArchive.map((i) => i.id)).not.toContain(issueId);
|
||||||
|
|
||||||
|
// Status/work update changes updatedAt (no new comment)
|
||||||
|
await db
|
||||||
|
.update(issues)
|
||||||
|
.set({
|
||||||
|
status: "in_progress",
|
||||||
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
||||||
|
})
|
||||||
|
.where(eq(issues.id, issueId));
|
||||||
|
|
||||||
|
// Should resurface because updatedAt > archivedAt
|
||||||
|
const afterUpdate = await svc.list(companyId, {
|
||||||
|
touchedByUserId: userId,
|
||||||
|
inboxArchivedByUserId: userId,
|
||||||
|
});
|
||||||
|
expect(afterUpdate.map((i) => i.id)).toContain(issueId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts and exposes last activity from comments and non-local issue activity logs", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const olderIssueId = randomUUID();
|
||||||
|
const commentIssueId = randomUUID();
|
||||||
|
const activityIssueId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(issues).values([
|
||||||
|
{
|
||||||
|
id: olderIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Older issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: commentIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Comment activity issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: activityIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Logged activity issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(issueComments).values({
|
||||||
|
companyId,
|
||||||
|
issueId: commentIssueId,
|
||||||
|
body: "New comment without touching issue.updatedAt",
|
||||||
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(activityLog).values([
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
actorType: "system",
|
||||||
|
actorId: "system",
|
||||||
|
action: "issue.document_updated",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: activityIssueId,
|
||||||
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user-1",
|
||||||
|
action: "issue.read_marked",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: olderIssueId,
|
||||||
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await svc.list(companyId, {});
|
||||||
|
|
||||||
|
expect(result.map((issue) => issue.id)).toEqual([
|
||||||
|
activityIssueId,
|
||||||
|
commentIssueId,
|
||||||
|
olderIssueId,
|
||||||
|
]);
|
||||||
|
expect(result.find((issue) => issue.id === activityIssueId)?.lastActivityAt?.toISOString()).toBe(
|
||||||
|
"2026-03-26T12:00:00.000Z",
|
||||||
|
);
|
||||||
|
expect(result.find((issue) => issue.id === commentIssueId)?.lastActivityAt?.toISOString()).toBe(
|
||||||
|
"2026-03-26T11:00:00.000Z",
|
||||||
|
);
|
||||||
|
expect(result.find((issue) => issue.id === olderIssueId)?.lastActivityAt?.toISOString()).toBe(
|
||||||
|
"2026-03-26T10:00:00.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
companies,
|
companies,
|
||||||
companyMemberships,
|
companyMemberships,
|
||||||
createDb,
|
createDb,
|
||||||
|
executionWorkspaces,
|
||||||
heartbeatRunEvents,
|
heartbeatRunEvents,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
instanceSettings,
|
instanceSettings,
|
||||||
issues,
|
issues,
|
||||||
principalPermissionGrants,
|
principalPermissionGrants,
|
||||||
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
routineRuns,
|
routineRuns,
|
||||||
routines,
|
routines,
|
||||||
@@ -102,6 +104,8 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
|||||||
await db.delete(heartbeatRuns);
|
await db.delete(heartbeatRuns);
|
||||||
await db.delete(agentWakeupRequests);
|
await db.delete(agentWakeupRequests);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
await db.delete(executionWorkspaces);
|
||||||
|
await db.delete(projectWorkspaces);
|
||||||
await db.delete(principalPermissionGrants);
|
await db.delete(principalPermissionGrants);
|
||||||
await db.delete(companyMemberships);
|
await db.delete(companyMemberships);
|
||||||
await db.delete(routines);
|
await db.delete(routines);
|
||||||
@@ -272,4 +276,136 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs routines with variable inputs and interpolates the execution issue description", async () => {
|
||||||
|
const { companyId, agentId, projectId, userId } = await seedFixture();
|
||||||
|
const app = await createApp({
|
||||||
|
type: "board",
|
||||||
|
userId,
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/companies/${companyId}/routines`)
|
||||||
|
.send({
|
||||||
|
projectId,
|
||||||
|
title: "Repository triage",
|
||||||
|
description: "Review {{repo}} for {{priority}} bugs",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
variables: [
|
||||||
|
{ name: "repo", type: "text", required: true },
|
||||||
|
{ name: "priority", type: "select", required: true, defaultValue: "high", options: ["high", "low"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
|
||||||
|
const runRes = await request(app)
|
||||||
|
.post(`/api/routines/${createRes.body.id}/run`)
|
||||||
|
.send({
|
||||||
|
source: "manual",
|
||||||
|
variables: { repo: "paperclip" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runRes.status).toBe(202);
|
||||||
|
expect(runRes.body.triggerPayload).toEqual({
|
||||||
|
variables: {
|
||||||
|
repo: "paperclip",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [issue] = await db
|
||||||
|
.select({ description: issues.description })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, runRes.body.linkedIssueId));
|
||||||
|
|
||||||
|
expect(issue?.description).toBe("Review paperclip for high bugs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists execution workspace selections from manual routine runs", async () => {
|
||||||
|
const { companyId, agentId, projectId, userId } = await seedFixture();
|
||||||
|
const projectWorkspaceId = randomUUID();
|
||||||
|
const executionWorkspaceId = randomUUID();
|
||||||
|
const app = await createApp({
|
||||||
|
type: "board",
|
||||||
|
userId,
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(projectWorkspaces).values({
|
||||||
|
id: projectWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
name: "Primary workspace",
|
||||||
|
isPrimary: true,
|
||||||
|
sharedWorkspaceKey: "routine-primary",
|
||||||
|
});
|
||||||
|
await db.insert(executionWorkspaces).values({
|
||||||
|
id: executionWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
mode: "isolated_workspace",
|
||||||
|
strategyType: "git_worktree",
|
||||||
|
name: "Routine worktree",
|
||||||
|
status: "active",
|
||||||
|
providerType: "git_worktree",
|
||||||
|
});
|
||||||
|
await db
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(projects.id, projectId));
|
||||||
|
await db.insert(instanceSettings).values({
|
||||||
|
experimental: { enableIsolatedWorkspaces: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/companies/${companyId}/routines`)
|
||||||
|
.send({
|
||||||
|
projectId,
|
||||||
|
title: "Workspace-aware routine",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
|
||||||
|
const runRes = await request(app)
|
||||||
|
.post(`/api/routines/${createRes.body.id}/run`)
|
||||||
|
.send({
|
||||||
|
source: "manual",
|
||||||
|
executionWorkspaceId,
|
||||||
|
executionWorkspacePreference: "reuse_existing",
|
||||||
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runRes.status).toBe(202);
|
||||||
|
|
||||||
|
const [issue] = await db
|
||||||
|
.select({
|
||||||
|
projectWorkspaceId: issues.projectWorkspaceId,
|
||||||
|
executionWorkspaceId: issues.executionWorkspaceId,
|
||||||
|
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||||
|
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||||
|
})
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, runRes.body.linkedIssueId));
|
||||||
|
|
||||||
|
expect(issue).toEqual({
|
||||||
|
projectWorkspaceId,
|
||||||
|
executionWorkspaceId,
|
||||||
|
executionWorkspacePreference: "reuse_existing",
|
||||||
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import {
|
|||||||
companySecrets,
|
companySecrets,
|
||||||
companySecretVersions,
|
companySecretVersions,
|
||||||
createDb,
|
createDb,
|
||||||
|
executionWorkspaces,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
|
instanceSettings,
|
||||||
issues,
|
issues,
|
||||||
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
routineRuns,
|
routineRuns,
|
||||||
routines,
|
routines,
|
||||||
@@ -20,6 +23,7 @@ import {
|
|||||||
startEmbeddedPostgresTestDatabase,
|
startEmbeddedPostgresTestDatabase,
|
||||||
} from "./helpers/embedded-postgres.js";
|
} from "./helpers/embedded-postgres.js";
|
||||||
import { issueService } from "../services/issues.ts";
|
import { issueService } from "../services/issues.ts";
|
||||||
|
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||||
import { routineService } from "../services/routines.ts";
|
import { routineService } from "../services/routines.ts";
|
||||||
|
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
@@ -49,9 +53,12 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||||||
await db.delete(companySecrets);
|
await db.delete(companySecrets);
|
||||||
await db.delete(heartbeatRuns);
|
await db.delete(heartbeatRuns);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
await db.delete(executionWorkspaces);
|
||||||
|
await db.delete(projectWorkspaces);
|
||||||
await db.delete(projects);
|
await db.delete(projects);
|
||||||
await db.delete(agents);
|
await db.delete(agents);
|
||||||
await db.delete(companies);
|
await db.delete(companies);
|
||||||
|
await db.delete(instanceSettings);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -317,6 +324,196 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||||||
expect(routineIssues[0]?.id).toBe(previousIssue.id);
|
expect(routineIssues[0]?.id).toBe(previousIssue.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("interpolates routine variables into the execution issue and stores resolved values", async () => {
|
||||||
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||||
|
const variableRoutine = await svc.create(
|
||||||
|
companyId,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: "repo triage",
|
||||||
|
description: "Review {{repo}} for {{priority}} bugs",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
priority: "medium",
|
||||||
|
status: "active",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
variables: [
|
||||||
|
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||||
|
{ name: "priority", label: null, type: "select", defaultValue: "high", required: true, options: ["high", "low"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = await svc.runRoutine(variableRoutine.id, {
|
||||||
|
source: "manual",
|
||||||
|
variables: { repo: "paperclip" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedIssue = await db
|
||||||
|
.select({ description: issues.description })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, run.linkedIssueId!))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
const storedRun = await db
|
||||||
|
.select({ triggerPayload: routineRuns.triggerPayload })
|
||||||
|
.from(routineRuns)
|
||||||
|
.where(eq(routineRuns.id, run.id))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
|
expect(storedIssue?.description).toBe("Review paperclip for high bugs");
|
||||||
|
expect(storedRun?.triggerPayload).toEqual({
|
||||||
|
variables: {
|
||||||
|
repo: "paperclip",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the selected execution workspace to manually triggered routine issues", async () => {
|
||||||
|
const { companyId, projectId, routine, svc } = await seedFixture();
|
||||||
|
const projectWorkspaceId = randomUUID();
|
||||||
|
const executionWorkspaceId = randomUUID();
|
||||||
|
|
||||||
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||||
|
await db
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(projects.id, projectId));
|
||||||
|
await db.insert(projectWorkspaces).values({
|
||||||
|
id: projectWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
name: "Primary workspace",
|
||||||
|
isPrimary: true,
|
||||||
|
sharedWorkspaceKey: "routine-primary",
|
||||||
|
});
|
||||||
|
await db.insert(executionWorkspaces).values({
|
||||||
|
id: executionWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
mode: "isolated_workspace",
|
||||||
|
strategyType: "git_worktree",
|
||||||
|
name: "Routine worktree",
|
||||||
|
status: "active",
|
||||||
|
providerType: "git_worktree",
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = await svc.runRoutine(routine.id, {
|
||||||
|
source: "manual",
|
||||||
|
executionWorkspaceId,
|
||||||
|
executionWorkspacePreference: "reuse_existing",
|
||||||
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedIssue = await db
|
||||||
|
.select({
|
||||||
|
projectWorkspaceId: issues.projectWorkspaceId,
|
||||||
|
executionWorkspaceId: issues.executionWorkspaceId,
|
||||||
|
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||||
|
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||||
|
})
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, run.linkedIssueId!))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
|
expect(storedIssue).toEqual({
|
||||||
|
projectWorkspaceId,
|
||||||
|
executionWorkspaceId,
|
||||||
|
executionWorkspacePreference: "reuse_existing",
|
||||||
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks schedule triggers when required variables do not have defaults", async () => {
|
||||||
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||||
|
const variableRoutine = await svc.create(
|
||||||
|
companyId,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: "repo triage",
|
||||||
|
description: "Review {{repo}}",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
priority: "medium",
|
||||||
|
status: "active",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
variables: [
|
||||||
|
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.createTrigger(variableRoutine.id, {
|
||||||
|
kind: "schedule",
|
||||||
|
label: "daily",
|
||||||
|
cronExpression: "0 10 * * *",
|
||||||
|
timezone: "UTC",
|
||||||
|
}, {}),
|
||||||
|
).rejects.toThrow(/require defaults for required variables/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats malformed stored defaults as missing when validating schedule triggers", async () => {
|
||||||
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||||
|
const variableRoutine = await svc.create(
|
||||||
|
companyId,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: "ship check",
|
||||||
|
description: "Review {{approved}}",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
priority: "medium",
|
||||||
|
status: "active",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
variables: [
|
||||||
|
{ name: "approved", label: null, type: "boolean", defaultValue: true, required: true, options: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(routines)
|
||||||
|
.set({
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
name: "approved",
|
||||||
|
label: null,
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "definitely",
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.where(eq(routines.id, variableRoutine.id));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.createTrigger(variableRoutine.id, {
|
||||||
|
kind: "schedule",
|
||||||
|
label: "daily",
|
||||||
|
cronExpression: "0 10 * * *",
|
||||||
|
timezone: "UTC",
|
||||||
|
}, {}),
|
||||||
|
).rejects.toThrow(/require defaults for required variables/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
|
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
|
||||||
const { routine, svc } = await seedFixture({
|
const { routine, svc } = await seedFixture({
|
||||||
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
||||||
|
|||||||
@@ -552,7 +552,7 @@ describe("realizeExecutionWorkspace", () => {
|
|||||||
} finally {
|
} finally {
|
||||||
process.chdir(previousCwd);
|
process.chdir(previousCwd);
|
||||||
}
|
}
|
||||||
});
|
}, 15_000);
|
||||||
|
|
||||||
it("records worktree setup and provision operations when a recorder is provided", async () => {
|
it("records worktree setup and provision operations when a recorder is provided", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
const repoRoot = await createTempRepo();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router, type Request } from "express";
|
|||||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
|
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable } from "@paperclipai/db";
|
||||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
agentSkillSyncSchema,
|
agentSkillSyncSchema,
|
||||||
@@ -220,6 +220,73 @@ export function agentRoutes(db: Db) {
|
|||||||
return allowedByGrant || canCreateAgents(actorAgent);
|
return allowedByGrant || canCreateAgents(actorAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildSkippedWakeupResponse(
|
||||||
|
agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>,
|
||||||
|
payload: Record<string, unknown> | null | undefined,
|
||||||
|
) {
|
||||||
|
const issueId = typeof payload?.issueId === "string" && payload.issueId.trim() ? payload.issueId : null;
|
||||||
|
if (!issueId) {
|
||||||
|
return {
|
||||||
|
status: "skipped" as const,
|
||||||
|
reason: "wakeup_skipped",
|
||||||
|
message: "Wakeup was skipped.",
|
||||||
|
issueId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentId: null,
|
||||||
|
executionAgentName: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const issue = await db
|
||||||
|
.select({
|
||||||
|
id: issuesTable.id,
|
||||||
|
executionRunId: issuesTable.executionRunId,
|
||||||
|
})
|
||||||
|
.from(issuesTable)
|
||||||
|
.where(and(eq(issuesTable.id, issueId), eq(issuesTable.companyId, agent.companyId)))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
|
if (!issue?.executionRunId) {
|
||||||
|
return {
|
||||||
|
status: "skipped" as const,
|
||||||
|
reason: "wakeup_skipped",
|
||||||
|
message: "Wakeup was skipped.",
|
||||||
|
issueId,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentId: null,
|
||||||
|
executionAgentName: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionRun = await heartbeat.getRun(issue.executionRunId);
|
||||||
|
if (!executionRun || (executionRun.status !== "queued" && executionRun.status !== "running")) {
|
||||||
|
return {
|
||||||
|
status: "skipped" as const,
|
||||||
|
reason: "wakeup_skipped",
|
||||||
|
message: "Wakeup was skipped.",
|
||||||
|
issueId,
|
||||||
|
executionRunId: issue.executionRunId,
|
||||||
|
executionAgentId: null,
|
||||||
|
executionAgentName: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionAgent = await svc.getById(executionRun.agentId);
|
||||||
|
const executionAgentName = executionAgent?.name ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "skipped" as const,
|
||||||
|
reason: "issue_execution_deferred",
|
||||||
|
message: executionAgentName
|
||||||
|
? `Wakeup was deferred because this issue is already being executed by ${executionAgentName}.`
|
||||||
|
: "Wakeup was deferred because this issue already has an active execution run.",
|
||||||
|
issueId,
|
||||||
|
executionRunId: executionRun.id,
|
||||||
|
executionAgentId: executionRun.agentId,
|
||||||
|
executionAgentName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
|
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||||
assertCompanyAccess(req, targetAgent.companyId);
|
assertCompanyAccess(req, targetAgent.companyId);
|
||||||
if (req.actor.type === "board") return;
|
if (req.actor.type === "board") return;
|
||||||
@@ -532,8 +599,15 @@ export function agentRoutes(db: Db) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([
|
||||||
|
"cursor",
|
||||||
|
"gemini_local",
|
||||||
|
"opencode_local",
|
||||||
|
"pi_local",
|
||||||
|
]);
|
||||||
|
|
||||||
function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) {
|
function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) {
|
||||||
return adapterType !== "claude_local";
|
return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildRuntimeSkillConfig(
|
async function buildRuntimeSkillConfig(
|
||||||
@@ -1994,7 +2068,7 @@ export function agentRoutes(db: Db) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!run) {
|
if (!run) {
|
||||||
res.status(202).json({ status: "skipped" });
|
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export function instanceSettingsRoutes(db: Db) {
|
|||||||
const svc = instanceSettingsService(db);
|
const svc = instanceSettingsService(db);
|
||||||
|
|
||||||
router.get("/instance/settings/general", async (req, res) => {
|
router.get("/instance/settings/general", async (req, res) => {
|
||||||
assertCanManageInstanceSettings(req);
|
// General settings (e.g. keyboardShortcuts) are readable by any
|
||||||
|
// authenticated board user. Only PATCH requires instance-admin.
|
||||||
|
if (req.actor.type !== "board") {
|
||||||
|
throw forbidden("Board access required");
|
||||||
|
}
|
||||||
res.json(await svc.getGeneral());
|
res.json(await svc.getGeneral());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,7 +60,11 @@ export function instanceSettingsRoutes(db: Db) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
router.get("/instance/settings/experimental", async (req, res) => {
|
router.get("/instance/settings/experimental", async (req, res) => {
|
||||||
assertCanManageInstanceSettings(req);
|
// Experimental settings are readable by any authenticated board user.
|
||||||
|
// Only PATCH requires instance-admin.
|
||||||
|
if (req.actor.type !== "board") {
|
||||||
|
throw forbidden("Board access required");
|
||||||
|
}
|
||||||
res.json(await svc.getExperimental());
|
res.json(await svc.getExperimental());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
CompanyPortabilitySidebarOrder,
|
CompanyPortabilitySidebarOrder,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanySkill,
|
CompanySkill,
|
||||||
|
RoutineVariable,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
ISSUE_PRIORITIES,
|
ISSUE_PRIORITIES,
|
||||||
@@ -523,7 +524,7 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
|||||||
claude_local: [
|
claude_local: [
|
||||||
{ path: ["timeoutSec"], value: 0 },
|
{ path: ["timeoutSec"], value: 0 },
|
||||||
{ path: ["graceSec"], value: 15 },
|
{ path: ["graceSec"], value: 15 },
|
||||||
{ path: ["maxTurnsPerRun"], value: 300 },
|
{ path: ["maxTurnsPerRun"], value: 1000 },
|
||||||
],
|
],
|
||||||
openclaw_gateway: [
|
openclaw_gateway: [
|
||||||
{ path: ["timeoutSec"], value: 120 },
|
{ path: ["timeoutSec"], value: 120 },
|
||||||
@@ -568,6 +569,29 @@ function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIss
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRoutineVariableExtension(value: unknown): RoutineVariable | null {
|
||||||
|
if (!isPlainRecord(value)) return null;
|
||||||
|
const name = asString(value.name);
|
||||||
|
if (!name) return null;
|
||||||
|
const type = asString(value.type) ?? "text";
|
||||||
|
if (!["text", "textarea", "number", "boolean", "select"].includes(type)) return null;
|
||||||
|
const options = Array.isArray(value.options)
|
||||||
|
? value.options.map((entry) => asString(entry)).filter((entry): entry is string => Boolean(entry))
|
||||||
|
: [];
|
||||||
|
const defaultValue =
|
||||||
|
typeof value.defaultValue === "string" || typeof value.defaultValue === "number" || typeof value.defaultValue === "boolean"
|
||||||
|
? value.defaultValue
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
label: asString(value.label),
|
||||||
|
type: type as RoutineVariable["type"],
|
||||||
|
defaultValue,
|
||||||
|
required: asBoolean(value.required) ?? true,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRoutineManifestEntry | null {
|
function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRoutineManifestEntry | null {
|
||||||
if (!isPlainRecord(value)) return null;
|
if (!isPlainRecord(value)) return null;
|
||||||
const triggers = Array.isArray(value.triggers)
|
const triggers = Array.isArray(value.triggers)
|
||||||
@@ -575,9 +599,15 @@ function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRouti
|
|||||||
.map((entry) => normalizeRoutineTriggerExtension(entry))
|
.map((entry) => normalizeRoutineTriggerExtension(entry))
|
||||||
.filter((entry): entry is CompanyPortabilityIssueRoutineTriggerManifestEntry => entry !== null)
|
.filter((entry): entry is CompanyPortabilityIssueRoutineTriggerManifestEntry => entry !== null)
|
||||||
: [];
|
: [];
|
||||||
|
const variables = Array.isArray(value.variables)
|
||||||
|
? value.variables
|
||||||
|
.map((entry) => normalizeRoutineVariableExtension(entry))
|
||||||
|
.filter((entry): entry is RoutineVariable => entry !== null)
|
||||||
|
: null;
|
||||||
const routine = {
|
const routine = {
|
||||||
concurrencyPolicy: asString(value.concurrencyPolicy),
|
concurrencyPolicy: asString(value.concurrencyPolicy),
|
||||||
catchUpPolicy: asString(value.catchUpPolicy),
|
catchUpPolicy: asString(value.catchUpPolicy),
|
||||||
|
variables,
|
||||||
triggers,
|
triggers,
|
||||||
};
|
};
|
||||||
return stripEmptyValues(routine) ? routine : null;
|
return stripEmptyValues(routine) ? routine : null;
|
||||||
@@ -587,6 +617,7 @@ function buildRoutineManifestFromLiveRoutine(routine: RoutineLike): CompanyPorta
|
|||||||
return {
|
return {
|
||||||
concurrencyPolicy: routine.concurrencyPolicy,
|
concurrencyPolicy: routine.concurrencyPolicy,
|
||||||
catchUpPolicy: routine.catchUpPolicy,
|
catchUpPolicy: routine.catchUpPolicy,
|
||||||
|
variables: routine.variables,
|
||||||
triggers: routine.triggers.map((trigger) => ({
|
triggers: routine.triggers.map((trigger) => ({
|
||||||
kind: trigger.kind,
|
kind: trigger.kind,
|
||||||
label: trigger.label ?? null,
|
label: trigger.label ?? null,
|
||||||
@@ -1086,11 +1117,13 @@ function resolvePortableRoutineDefinition(
|
|||||||
? {
|
? {
|
||||||
concurrencyPolicy: issue.routine.concurrencyPolicy,
|
concurrencyPolicy: issue.routine.concurrencyPolicy,
|
||||||
catchUpPolicy: issue.routine.catchUpPolicy,
|
catchUpPolicy: issue.routine.catchUpPolicy,
|
||||||
|
variables: issue.routine.variables ?? null,
|
||||||
triggers: [...issue.routine.triggers],
|
triggers: [...issue.routine.triggers],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
concurrencyPolicy: null,
|
concurrencyPolicy: null,
|
||||||
catchUpPolicy: null,
|
catchUpPolicy: null,
|
||||||
|
variables: null,
|
||||||
triggers: [] as CompanyPortabilityIssueRoutineTriggerManifestEntry[],
|
triggers: [] as CompanyPortabilityIssueRoutineTriggerManifestEntry[],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3204,6 +3237,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||||||
priority: routine.priority !== "medium" ? routine.priority : undefined,
|
priority: routine.priority !== "medium" ? routine.priority : undefined,
|
||||||
concurrencyPolicy: routine.concurrencyPolicy !== "coalesce_if_active" ? routine.concurrencyPolicy : undefined,
|
concurrencyPolicy: routine.concurrencyPolicy !== "coalesce_if_active" ? routine.concurrencyPolicy : undefined,
|
||||||
catchUpPolicy: routine.catchUpPolicy !== "skip_missed" ? routine.catchUpPolicy : undefined,
|
catchUpPolicy: routine.catchUpPolicy !== "skip_missed" ? routine.catchUpPolicy : undefined,
|
||||||
|
variables: (routine.variables ?? []).length > 0 ? routine.variables : undefined,
|
||||||
triggers: routine.triggers.map((trigger) => stripEmptyValues({
|
triggers: routine.triggers.map((trigger) => stripEmptyValues({
|
||||||
kind: trigger.kind,
|
kind: trigger.kind,
|
||||||
label: trigger.label ?? null,
|
label: trigger.label ?? null,
|
||||||
@@ -4173,6 +4207,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||||||
const routineDefinition = resolvedRoutine.routine ?? {
|
const routineDefinition = resolvedRoutine.routine ?? {
|
||||||
concurrencyPolicy: null,
|
concurrencyPolicy: null,
|
||||||
catchUpPolicy: null,
|
catchUpPolicy: null,
|
||||||
|
variables: null,
|
||||||
triggers: [],
|
triggers: [],
|
||||||
};
|
};
|
||||||
const createdRoutine = await routines.create(targetCompany.id, {
|
const createdRoutine = await routines.create(targetCompany.id, {
|
||||||
@@ -4196,6 +4231,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||||||
routineDefinition.catchUpPolicy && ROUTINE_CATCH_UP_POLICIES.includes(routineDefinition.catchUpPolicy as any)
|
routineDefinition.catchUpPolicy && ROUTINE_CATCH_UP_POLICIES.includes(routineDefinition.catchUpPolicy as any)
|
||||||
? routineDefinition.catchUpPolicy as typeof ROUTINE_CATCH_UP_POLICIES[number]
|
? routineDefinition.catchUpPolicy as typeof ROUTINE_CATCH_UP_POLICIES[number]
|
||||||
: "skip_missed",
|
: "skip_missed",
|
||||||
|
variables: routineDefinition.variables ?? [],
|
||||||
}, {
|
}, {
|
||||||
agentId: null,
|
agentId: null,
|
||||||
userId: actorUserId ?? null,
|
userId: actorUserId ?? null,
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ type IssueUserCommentStats = {
|
|||||||
myLastCommentAt: Date | null;
|
myLastCommentAt: Date | null;
|
||||||
lastExternalCommentAt: Date | null;
|
lastExternalCommentAt: Date | null;
|
||||||
};
|
};
|
||||||
|
type IssueLastActivityStat = {
|
||||||
|
issueId: string;
|
||||||
|
latestCommentAt: Date | null;
|
||||||
|
latestLogAt: Date | null;
|
||||||
|
};
|
||||||
type IssueUserContextInput = {
|
type IssueUserContextInput = {
|
||||||
createdByUserId: string | null;
|
createdByUserId: string | null;
|
||||||
assigneeUserId: string | null;
|
assigneeUserId: string | null;
|
||||||
@@ -262,8 +267,8 @@ function issueLastActivityAtExpr(companyId: string, userId: string) {
|
|||||||
const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId);
|
const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId);
|
||||||
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
||||||
return sql<Date>`
|
return sql<Date>`
|
||||||
COALESCE(
|
GREATEST(
|
||||||
${lastExternalCommentAt},
|
COALESCE(${lastExternalCommentAt}, to_timestamp(0)),
|
||||||
CASE
|
CASE
|
||||||
WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0))
|
WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0))
|
||||||
THEN ${issues.updatedAt}
|
THEN ${issues.updatedAt}
|
||||||
@@ -273,6 +278,52 @@ function issueLastActivityAtExpr(companyId: string, userId: string) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS = [
|
||||||
|
"issue.read_marked",
|
||||||
|
"issue.read_unmarked",
|
||||||
|
"issue.inbox_archived",
|
||||||
|
"issue.inbox_unarchived",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function issueLatestCommentAtExpr(companyId: string) {
|
||||||
|
return sql<Date | null>`
|
||||||
|
(
|
||||||
|
SELECT MAX(${issueComments.createdAt})
|
||||||
|
FROM ${issueComments}
|
||||||
|
WHERE ${issueComments.issueId} = ${issues.id}
|
||||||
|
AND ${issueComments.companyId} = ${companyId}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueLatestLogAtExpr(companyId: string) {
|
||||||
|
return sql<Date | null>`
|
||||||
|
(
|
||||||
|
SELECT MAX(${activityLog.createdAt})
|
||||||
|
FROM ${activityLog}
|
||||||
|
WHERE ${activityLog.companyId} = ${companyId}
|
||||||
|
AND ${activityLog.entityType} = 'issue'
|
||||||
|
AND ${activityLog.entityId} = ${issues.id}::text
|
||||||
|
AND ${activityLog.action} NOT IN (${sql.join(
|
||||||
|
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
|
||||||
|
sql`, `,
|
||||||
|
)})
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueCanonicalLastActivityAtExpr(companyId: string) {
|
||||||
|
const latestCommentAt = issueLatestCommentAtExpr(companyId);
|
||||||
|
const latestLogAt = issueLatestLogAtExpr(companyId);
|
||||||
|
return sql<Date>`
|
||||||
|
GREATEST(
|
||||||
|
${issues.updatedAt},
|
||||||
|
COALESCE(${latestCommentAt}, to_timestamp(0)),
|
||||||
|
COALESCE(${latestLogAt}, to_timestamp(0))
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function unreadForUserCondition(companyId: string, userId: string) {
|
function unreadForUserCondition(companyId: string, userId: string) {
|
||||||
const touchedCondition = touchedByUserCondition(companyId, userId);
|
const touchedCondition = touchedByUserCondition(companyId, userId);
|
||||||
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
||||||
@@ -383,6 +434,19 @@ export function deriveIssueUserContext(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function latestIssueActivityAt(...values: Array<Date | string | null | undefined>): Date | null {
|
||||||
|
const normalized = values
|
||||||
|
.map((value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
})
|
||||||
|
.filter((value): value is Date => value instanceof Date)
|
||||||
|
.sort((a, b) => b.getTime() - a.getTime());
|
||||||
|
return normalized[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
|
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
|
||||||
const map = new Map<string, IssueLabelRow[]>();
|
const map = new Map<string, IssueLabelRow[]>();
|
||||||
if (issueIds.length === 0) return map;
|
if (issueIds.length === 0) return map;
|
||||||
@@ -749,66 +813,158 @@ export function issueService(db: Db) {
|
|||||||
ELSE 6
|
ELSE 6
|
||||||
END
|
END
|
||||||
`;
|
`;
|
||||||
|
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(issues)
|
.from(issues)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
|
.orderBy(
|
||||||
|
hasSearch ? asc(searchOrder) : asc(priorityOrder),
|
||||||
|
asc(priorityOrder),
|
||||||
|
desc(canonicalLastActivityAt),
|
||||||
|
desc(issues.updatedAt),
|
||||||
|
);
|
||||||
const withLabels = await withIssueLabels(db, rows);
|
const withLabels = await withIssueLabels(db, rows);
|
||||||
const runMap = await activeRunMapForIssues(db, withLabels);
|
const runMap = await activeRunMapForIssues(db, withLabels);
|
||||||
const withRuns = withActiveRuns(withLabels, runMap);
|
const withRuns = withActiveRuns(withLabels, runMap);
|
||||||
if (!contextUserId || withRuns.length === 0) {
|
if (withRuns.length === 0) {
|
||||||
return withRuns;
|
return withRuns;
|
||||||
}
|
}
|
||||||
|
|
||||||
const issueIds = withRuns.map((row) => row.id);
|
const issueIds = withRuns.map((row) => row.id);
|
||||||
const statsRows = await db
|
const [statsRows, readRows, lastActivityRows] = await Promise.all([
|
||||||
.select({
|
contextUserId
|
||||||
issueId: issueComments.issueId,
|
? db
|
||||||
myLastCommentAt: sql<Date | null>`
|
.select({
|
||||||
MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
|
issueId: issueComments.issueId,
|
||||||
`,
|
myLastCommentAt: sql<Date | null>`
|
||||||
lastExternalCommentAt: sql<Date | null>`
|
MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
|
||||||
MAX(
|
`,
|
||||||
CASE
|
lastExternalCommentAt: sql<Date | null>`
|
||||||
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
|
MAX(
|
||||||
THEN ${issueComments.createdAt}
|
CASE
|
||||||
END
|
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
|
||||||
|
THEN ${issueComments.createdAt}
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.from(issueComments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(issueComments.companyId, companyId),
|
||||||
|
inArray(issueComments.issueId, issueIds),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
`,
|
.groupBy(issueComments.issueId)
|
||||||
})
|
: Promise.resolve([]),
|
||||||
.from(issueComments)
|
contextUserId
|
||||||
.where(
|
? db
|
||||||
and(
|
.select({
|
||||||
eq(issueComments.companyId, companyId),
|
issueId: issueReadStates.issueId,
|
||||||
inArray(issueComments.issueId, issueIds),
|
myLastReadAt: issueReadStates.lastReadAt,
|
||||||
),
|
})
|
||||||
)
|
.from(issueReadStates)
|
||||||
.groupBy(issueComments.issueId);
|
.where(
|
||||||
const readRows = await db
|
and(
|
||||||
.select({
|
eq(issueReadStates.companyId, companyId),
|
||||||
issueId: issueReadStates.issueId,
|
eq(issueReadStates.userId, contextUserId),
|
||||||
myLastReadAt: issueReadStates.lastReadAt,
|
inArray(issueReadStates.issueId, issueIds),
|
||||||
})
|
),
|
||||||
.from(issueReadStates)
|
)
|
||||||
.where(
|
: Promise.resolve([]),
|
||||||
and(
|
Promise.all([
|
||||||
eq(issueReadStates.companyId, companyId),
|
db
|
||||||
eq(issueReadStates.userId, contextUserId),
|
.select({
|
||||||
inArray(issueReadStates.issueId, issueIds),
|
issueId: issueComments.issueId,
|
||||||
),
|
latestCommentAt: sql<Date | null>`MAX(${issueComments.createdAt})`,
|
||||||
);
|
})
|
||||||
|
.from(issueComments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(issueComments.companyId, companyId),
|
||||||
|
inArray(issueComments.issueId, issueIds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(issueComments.issueId),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
issueId: activityLog.entityId,
|
||||||
|
latestLogAt: sql<Date | null>`MAX(${activityLog.createdAt})`,
|
||||||
|
})
|
||||||
|
.from(activityLog)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(activityLog.companyId, companyId),
|
||||||
|
eq(activityLog.entityType, "issue"),
|
||||||
|
inArray(activityLog.entityId, issueIds),
|
||||||
|
sql`${activityLog.action} NOT IN (${sql.join(
|
||||||
|
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(activityLog.entityId),
|
||||||
|
]).then(([commentRows, logRows]) => {
|
||||||
|
const byIssueId = new Map<string, IssueLastActivityStat>();
|
||||||
|
for (const row of commentRows) {
|
||||||
|
byIssueId.set(row.issueId, {
|
||||||
|
issueId: row.issueId,
|
||||||
|
latestCommentAt: row.latestCommentAt,
|
||||||
|
latestLogAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of logRows) {
|
||||||
|
const existing = byIssueId.get(row.issueId);
|
||||||
|
if (existing) existing.latestLogAt = row.latestLogAt;
|
||||||
|
else {
|
||||||
|
byIssueId.set(row.issueId, {
|
||||||
|
issueId: row.issueId,
|
||||||
|
latestCommentAt: null,
|
||||||
|
latestLogAt: row.latestLogAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byIssueId.values()];
|
||||||
|
}),
|
||||||
|
]);
|
||||||
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
||||||
|
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
|
||||||
|
|
||||||
|
if (!contextUserId) {
|
||||||
|
return withRuns.map((row) => {
|
||||||
|
const activity = lastActivityByIssueId.get(row.id);
|
||||||
|
const lastActivityAt = latestIssueActivityAt(
|
||||||
|
row.updatedAt,
|
||||||
|
activity?.latestCommentAt ?? null,
|
||||||
|
activity?.latestLogAt ?? null,
|
||||||
|
) ?? row.updatedAt;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
lastActivityAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
|
const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
|
||||||
|
|
||||||
return withRuns.map((row) => ({
|
return withRuns.map((row) => {
|
||||||
...row,
|
const activity = lastActivityByIssueId.get(row.id);
|
||||||
...deriveIssueUserContext(row, contextUserId, {
|
const lastActivityAt = latestIssueActivityAt(
|
||||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
row.updatedAt,
|
||||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
activity?.latestCommentAt ?? null,
|
||||||
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
activity?.latestLogAt ?? null,
|
||||||
}),
|
) ?? row.updatedAt;
|
||||||
}));
|
return {
|
||||||
|
...row,
|
||||||
|
lastActivityAt,
|
||||||
|
...deriveIssueUserContext(row, contextUserId, {
|
||||||
|
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||||
|
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||||
|
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => {
|
countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => {
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ import type {
|
|||||||
RoutineRunSummary,
|
RoutineRunSummary,
|
||||||
RoutineTrigger,
|
RoutineTrigger,
|
||||||
RoutineTriggerSecretMaterial,
|
RoutineTriggerSecretMaterial,
|
||||||
|
RoutineVariable,
|
||||||
RunRoutine,
|
RunRoutine,
|
||||||
UpdateRoutine,
|
UpdateRoutine,
|
||||||
UpdateRoutineTrigger,
|
UpdateRoutineTrigger,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
interpolateRoutineTemplate,
|
||||||
|
stringifyRoutineVariableValue,
|
||||||
|
syncRoutineVariablesWithTemplate,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||||
import { logger } from "../middleware/logger.js";
|
import { logger } from "../middleware/logger.js";
|
||||||
import { issueService } from "./issues.js";
|
import { issueService } from "./issues.js";
|
||||||
@@ -138,6 +144,151 @@ function normalizeWebhookTimestampMs(rawTimestamp: string) {
|
|||||||
return parsed > 1e12 ? parsed : parsed * 1000;
|
return parsed > 1e12 ? parsed : parsed * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBooleanVariableValue(name: string, raw: unknown) {
|
||||||
|
if (typeof raw === "boolean") return raw;
|
||||||
|
if (typeof raw === "number" && (raw === 0 || raw === 1)) return raw === 1;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
|
||||||
|
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
|
||||||
|
}
|
||||||
|
throw unprocessable(`Variable "${name}" must be a boolean`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumberVariableValue(name: string, raw: unknown) {
|
||||||
|
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
||||||
|
if (typeof raw === "string" && raw.trim().length > 0) {
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
throw unprocessable(`Variable "${name}" must be a number`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoutineVariableValue(variable: RoutineVariable, raw: unknown): string | number | boolean | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (variable.type === "boolean") return parseBooleanVariableValue(variable.name, raw);
|
||||||
|
if (variable.type === "number") return parseNumberVariableValue(variable.name, raw);
|
||||||
|
|
||||||
|
const normalized = stringifyRoutineVariableValue(raw);
|
||||||
|
if (variable.type === "select") {
|
||||||
|
if (!variable.options.includes(normalized)) {
|
||||||
|
throw unprocessable(`Variable "${variable.name}" must match one of: ${variable.options.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingRoutineVariableValue(value: string | number | boolean | null) {
|
||||||
|
return value == null || (typeof value === "string" && value.trim().length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRoutineVariableDefinitions(variables: RoutineVariable[]) {
|
||||||
|
for (const variable of variables) {
|
||||||
|
if (variable.defaultValue != null) {
|
||||||
|
normalizeRoutineVariableValue(variable, variable.defaultValue);
|
||||||
|
}
|
||||||
|
if (variable.type === "select" && variable.options.length === 0) {
|
||||||
|
throw unprocessable(`Variable "${variable.name}" must define at least one option`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRoutineVariableInputs(
|
||||||
|
variables: Array<Partial<RoutineVariable> & Pick<RoutineVariable, "name">> | null | undefined,
|
||||||
|
): RoutineVariable[] {
|
||||||
|
return (variables ?? []).map((variable) => ({
|
||||||
|
name: variable.name,
|
||||||
|
label: variable.label ?? null,
|
||||||
|
type: variable.type ?? "text",
|
||||||
|
defaultValue: variable.defaultValue ?? null,
|
||||||
|
required: variable.required ?? true,
|
||||||
|
options: variable.options ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertScheduleCompatibleVariables(variables: RoutineVariable[]) {
|
||||||
|
const missingDefaults = variables
|
||||||
|
.filter((variable) => variable.required)
|
||||||
|
.filter((variable) => {
|
||||||
|
try {
|
||||||
|
return isMissingRoutineVariableValue(normalizeRoutineVariableValue(variable, variable.defaultValue));
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map((variable) => variable.name);
|
||||||
|
if (missingDefaults.length > 0) {
|
||||||
|
throw unprocessable(
|
||||||
|
`Scheduled routines require defaults for required variables: ${missingDefaults.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectProvidedRoutineVariables(
|
||||||
|
source: "schedule" | "manual" | "api" | "webhook",
|
||||||
|
payload: Record<string, unknown> | null | undefined,
|
||||||
|
variables: Record<string, unknown> | null | undefined,
|
||||||
|
) {
|
||||||
|
const nestedVariables = isPlainRecord(payload) && isPlainRecord(payload.variables) ? payload.variables : {};
|
||||||
|
const provided = {
|
||||||
|
...(source === "webhook" && payload ? payload : {}),
|
||||||
|
...nestedVariables,
|
||||||
|
...(variables ?? {}),
|
||||||
|
};
|
||||||
|
delete provided.variables;
|
||||||
|
return provided;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoutineVariableValues(
|
||||||
|
variables: RoutineVariable[],
|
||||||
|
input: {
|
||||||
|
source: "schedule" | "manual" | "api" | "webhook";
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
variables?: Record<string, unknown> | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (variables.length === 0) return {} as Record<string, string | number | boolean>;
|
||||||
|
const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables);
|
||||||
|
const resolved: Record<string, string | number | boolean> = {};
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
for (const variable of variables) {
|
||||||
|
const candidate = provided[variable.name] !== undefined ? provided[variable.name] : variable.defaultValue;
|
||||||
|
const normalized = normalizeRoutineVariableValue(variable, candidate);
|
||||||
|
if (normalized == null || (typeof normalized === "string" && normalized.trim().length === 0)) {
|
||||||
|
if (variable.required) missing.push(variable.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolved[variable.name] = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw unprocessable(`Missing routine variables: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRoutineRunPayload(
|
||||||
|
payload: Record<string, unknown> | null | undefined,
|
||||||
|
variables: Record<string, string | number | boolean>,
|
||||||
|
) {
|
||||||
|
if (Object.keys(variables).length === 0) return payload ?? null;
|
||||||
|
if (!payload) return { variables };
|
||||||
|
const existingVariables = isPlainRecord(payload.variables) ? payload.variables : {};
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
variables: {
|
||||||
|
...existingVariables,
|
||||||
|
...variables,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
|
export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
|
||||||
const issueSvc = issueService(db);
|
const issueSvc = issueService(db);
|
||||||
const secretsSvc = secretService(db);
|
const secretsSvc = secretService(db);
|
||||||
@@ -515,8 +666,15 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
trigger: typeof routineTriggers.$inferSelect | null;
|
trigger: typeof routineTriggers.$inferSelect | null;
|
||||||
source: "schedule" | "manual" | "api" | "webhook";
|
source: "schedule" | "manual" | "api" | "webhook";
|
||||||
payload?: Record<string, unknown> | null;
|
payload?: Record<string, unknown> | null;
|
||||||
|
variables?: Record<string, unknown> | null;
|
||||||
idempotencyKey?: string | null;
|
idempotencyKey?: string | null;
|
||||||
|
executionWorkspaceId?: string | null;
|
||||||
|
executionWorkspacePreference?: string | null;
|
||||||
|
executionWorkspaceSettings?: Record<string, unknown> | null;
|
||||||
}) {
|
}) {
|
||||||
|
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
|
||||||
|
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
|
||||||
|
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
|
||||||
const run = await db.transaction(async (tx) => {
|
const run = await db.transaction(async (tx) => {
|
||||||
const txDb = tx as unknown as Db;
|
const txDb = tx as unknown as Db;
|
||||||
await tx.execute(
|
await tx.execute(
|
||||||
@@ -553,7 +711,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
status: "received",
|
status: "received",
|
||||||
triggeredAt,
|
triggeredAt,
|
||||||
idempotencyKey: input.idempotencyKey ?? null,
|
idempotencyKey: input.idempotencyKey ?? null,
|
||||||
triggerPayload: input.payload ?? null,
|
triggerPayload,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -589,13 +747,16 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
goalId: input.routine.goalId,
|
goalId: input.routine.goalId,
|
||||||
parentId: input.routine.parentIssueId,
|
parentId: input.routine.parentIssueId,
|
||||||
title: input.routine.title,
|
title: input.routine.title,
|
||||||
description: input.routine.description,
|
description,
|
||||||
status: "todo",
|
status: "todo",
|
||||||
priority: input.routine.priority,
|
priority: input.routine.priority,
|
||||||
assigneeAgentId: input.routine.assigneeAgentId,
|
assigneeAgentId: input.routine.assigneeAgentId,
|
||||||
originKind: "routine_execution",
|
originKind: "routine_execution",
|
||||||
originId: input.routine.id,
|
originId: input.routine.id,
|
||||||
originRunId: createdRun.id,
|
originRunId: createdRun.id,
|
||||||
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||||
|
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
||||||
|
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const isOpenExecutionConflict =
|
const isOpenExecutionConflict =
|
||||||
@@ -824,6 +985,11 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
await assertAssignableAgent(companyId, input.assigneeAgentId);
|
await assertAssignableAgent(companyId, input.assigneeAgentId);
|
||||||
if (input.goalId) await assertGoal(companyId, input.goalId);
|
if (input.goalId) await assertGoal(companyId, input.goalId);
|
||||||
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
||||||
|
const variables = syncRoutineVariablesWithTemplate(
|
||||||
|
input.description,
|
||||||
|
sanitizeRoutineVariableInputs(input.variables),
|
||||||
|
);
|
||||||
|
assertRoutineVariableDefinitions(variables);
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(routines)
|
.insert(routines)
|
||||||
.values({
|
.values({
|
||||||
@@ -838,6 +1004,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
status: input.status,
|
status: input.status,
|
||||||
concurrencyPolicy: input.concurrencyPolicy,
|
concurrencyPolicy: input.concurrencyPolicy,
|
||||||
catchUpPolicy: input.catchUpPolicy,
|
catchUpPolicy: input.catchUpPolicy,
|
||||||
|
variables,
|
||||||
createdByAgentId: actor.agentId ?? null,
|
createdByAgentId: actor.agentId ?? null,
|
||||||
createdByUserId: actor.userId ?? null,
|
createdByUserId: actor.userId ?? null,
|
||||||
updatedByAgentId: actor.agentId ?? null,
|
updatedByAgentId: actor.agentId ?? null,
|
||||||
@@ -852,10 +1019,31 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
const nextProjectId = patch.projectId ?? existing.projectId;
|
const nextProjectId = patch.projectId ?? existing.projectId;
|
||||||
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
|
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
|
||||||
|
const nextDescription = patch.description === undefined ? existing.description : patch.description;
|
||||||
|
const nextVariables = syncRoutineVariablesWithTemplate(
|
||||||
|
nextDescription,
|
||||||
|
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
||||||
|
);
|
||||||
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
|
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
|
||||||
if (patch.assigneeAgentId) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId);
|
if (patch.assigneeAgentId) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId);
|
||||||
if (patch.goalId) await assertGoal(existing.companyId, patch.goalId);
|
if (patch.goalId) await assertGoal(existing.companyId, patch.goalId);
|
||||||
if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId);
|
if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId);
|
||||||
|
assertRoutineVariableDefinitions(nextVariables);
|
||||||
|
const enabledScheduleTriggers = await db
|
||||||
|
.select({ id: routineTriggers.id })
|
||||||
|
.from(routineTriggers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(routineTriggers.routineId, existing.id),
|
||||||
|
eq(routineTriggers.kind, "schedule"),
|
||||||
|
eq(routineTriggers.enabled, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.then((rows) => rows.length > 0);
|
||||||
|
if (enabledScheduleTriggers) {
|
||||||
|
assertScheduleCompatibleVariables(nextVariables);
|
||||||
|
}
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(routines)
|
.update(routines)
|
||||||
.set({
|
.set({
|
||||||
@@ -863,12 +1051,13 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
|
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
|
||||||
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
|
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
|
||||||
title: patch.title ?? existing.title,
|
title: patch.title ?? existing.title,
|
||||||
description: patch.description === undefined ? existing.description : patch.description,
|
description: nextDescription,
|
||||||
assigneeAgentId: nextAssigneeAgentId,
|
assigneeAgentId: nextAssigneeAgentId,
|
||||||
priority: patch.priority ?? existing.priority,
|
priority: patch.priority ?? existing.priority,
|
||||||
status: patch.status ?? existing.status,
|
status: patch.status ?? existing.status,
|
||||||
concurrencyPolicy: patch.concurrencyPolicy ?? existing.concurrencyPolicy,
|
concurrencyPolicy: patch.concurrencyPolicy ?? existing.concurrencyPolicy,
|
||||||
catchUpPolicy: patch.catchUpPolicy ?? existing.catchUpPolicy,
|
catchUpPolicy: patch.catchUpPolicy ?? existing.catchUpPolicy,
|
||||||
|
variables: nextVariables,
|
||||||
updatedByAgentId: actor.agentId ?? null,
|
updatedByAgentId: actor.agentId ?? null,
|
||||||
updatedByUserId: actor.userId ?? null,
|
updatedByUserId: actor.userId ?? null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -892,6 +1081,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
let nextRunAt: Date | null = null;
|
let nextRunAt: Date | null = null;
|
||||||
|
|
||||||
if (input.kind === "schedule") {
|
if (input.kind === "schedule") {
|
||||||
|
assertScheduleCompatibleVariables(routine.variables ?? []);
|
||||||
const timeZone = input.timezone || "UTC";
|
const timeZone = input.timezone || "UTC";
|
||||||
assertTimeZone(timeZone);
|
assertTimeZone(timeZone);
|
||||||
const error = validateCron(input.cronExpression);
|
const error = validateCron(input.cronExpression);
|
||||||
@@ -947,6 +1137,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
let timezone = existing.timezone;
|
let timezone = existing.timezone;
|
||||||
|
|
||||||
if (existing.kind === "schedule") {
|
if (existing.kind === "schedule") {
|
||||||
|
const routine = await getRoutineById(existing.routineId);
|
||||||
|
if (!routine) throw notFound("Routine not found");
|
||||||
if (patch.cronExpression !== undefined) {
|
if (patch.cronExpression !== undefined) {
|
||||||
if (patch.cronExpression == null) throw unprocessable("Scheduled triggers require cronExpression");
|
if (patch.cronExpression == null) throw unprocessable("Scheduled triggers require cronExpression");
|
||||||
const error = validateCron(patch.cronExpression);
|
const error = validateCron(patch.cronExpression);
|
||||||
@@ -961,6 +1153,9 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
if (cronExpression && timezone) {
|
if (cronExpression && timezone) {
|
||||||
nextRunAt = nextCronTickInTimeZone(cronExpression, timezone, new Date());
|
nextRunAt = nextCronTickInTimeZone(cronExpression, timezone, new Date());
|
||||||
}
|
}
|
||||||
|
if ((patch.enabled ?? existing.enabled) === true) {
|
||||||
|
assertScheduleCompatibleVariables(routine.variables ?? []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
@@ -1034,7 +1229,12 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
trigger,
|
trigger,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
payload: input.payload as Record<string, unknown> | null | undefined,
|
payload: input.payload as Record<string, unknown> | null | undefined,
|
||||||
|
variables: input.variables as Record<string, unknown> | null | undefined,
|
||||||
idempotencyKey: input.idempotencyKey,
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||||
|
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
||||||
|
executionWorkspaceSettings:
|
||||||
|
(input.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1097,6 +1297,9 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
trigger,
|
trigger,
|
||||||
source: "webhook",
|
source: "webhook",
|
||||||
payload: input.payload,
|
payload: input.payload,
|
||||||
|
variables: isPlainRecord(input.payload) && isPlainRecord(input.payload.variables)
|
||||||
|
? input.payload.variables
|
||||||
|
: null,
|
||||||
idempotencyKey: input.idempotencyKey,
|
idempotencyKey: input.idempotencyKey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
AgentKeyCreated,
|
AgentKeyCreated,
|
||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
AgentTaskSession,
|
AgentTaskSession,
|
||||||
|
AgentWakeupResponse,
|
||||||
HeartbeatRun,
|
HeartbeatRun,
|
||||||
Approval,
|
Approval,
|
||||||
AgentConfigRevision,
|
AgentConfigRevision,
|
||||||
@@ -189,7 +190,7 @@ export const agentsApi = {
|
|||||||
idempotencyKey?: string | null;
|
idempotencyKey?: string | null;
|
||||||
},
|
},
|
||||||
companyId?: string,
|
companyId?: string,
|
||||||
) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data),
|
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
|
||||||
loginWithClaude: (id: string, companyId?: string) =>
|
loginWithClaude: (id: string, companyId?: string) =>
|
||||||
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
||||||
availableSkills: () =>
|
availableSkills: () =>
|
||||||
|
|||||||
@@ -113,4 +113,27 @@ describe("IssueRow", () => {
|
|||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves the issue detail breadcrumb source and href in the link target", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const issue = createIssue();
|
||||||
|
const state = {
|
||||||
|
issueDetailBreadcrumb: { label: "Inbox", href: "/PAP/inbox/mine" },
|
||||||
|
issueDetailSource: "inbox",
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<IssueRow issue={issue} issueLinkState={state} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toContain(
|
||||||
|
"/issues/PAP-1?from=inbox&fromHref=%2FPAP%2Finbox%2Fmine",
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) {
|
|||||||
return "shared_workspace";
|
return "shared_workspace";
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
|
function shouldPresentExistingWorkspaceSelection(issue: {
|
||||||
|
executionWorkspaceId: string | null;
|
||||||
|
executionWorkspacePreference: string | null;
|
||||||
|
executionWorkspaceSettings: Issue["executionWorkspaceSettings"];
|
||||||
|
currentExecutionWorkspace?: ExecutionWorkspace | null;
|
||||||
|
}) {
|
||||||
const persistedMode =
|
const persistedMode =
|
||||||
issue.currentExecutionWorkspace?.mode
|
issue.currentExecutionWorkspace?.mode
|
||||||
?? issue.executionWorkspaceSettings?.mode
|
?? issue.executionWorkspaceSettings?.mode
|
||||||
@@ -156,19 +161,44 @@ function statusBadge(status: string) {
|
|||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
interface IssueWorkspaceCardProps {
|
interface IssueWorkspaceCardProps {
|
||||||
issue: Issue;
|
issue: Omit<
|
||||||
|
Pick<
|
||||||
|
Issue,
|
||||||
|
| "companyId"
|
||||||
|
| "projectId"
|
||||||
|
| "projectWorkspaceId"
|
||||||
|
| "executionWorkspaceId"
|
||||||
|
| "executionWorkspacePreference"
|
||||||
|
| "executionWorkspaceSettings"
|
||||||
|
>,
|
||||||
|
"companyId"
|
||||||
|
> & {
|
||||||
|
companyId: string | null;
|
||||||
|
currentExecutionWorkspace?: ExecutionWorkspace | null;
|
||||||
|
};
|
||||||
project: { id: string; executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null; defaultProjectWorkspaceId?: string | null } | null; workspaces?: Array<{ id: string; isPrimary: boolean }> } | null;
|
project: { id: string; executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null; defaultProjectWorkspaceId?: string | null } | null; workspaces?: Array<{ id: string; isPrimary: boolean }> } | null;
|
||||||
onUpdate: (data: Record<string, unknown>) => void;
|
onUpdate: (data: Record<string, unknown>) => void;
|
||||||
|
initialEditing?: boolean;
|
||||||
|
livePreview?: boolean;
|
||||||
|
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceCardProps) {
|
export function IssueWorkspaceCard({
|
||||||
|
issue,
|
||||||
|
project,
|
||||||
|
onUpdate,
|
||||||
|
initialEditing = false,
|
||||||
|
livePreview = false,
|
||||||
|
onDraftChange,
|
||||||
|
}: IssueWorkspaceCardProps) {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const companyId = issue.companyId ?? selectedCompanyId;
|
const companyId = issue.companyId ?? selectedCompanyId;
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(initialEditing);
|
||||||
|
|
||||||
const { data: experimentalSettings } = useQuery({
|
const { data: experimentalSettings } = useQuery({
|
||||||
queryKey: queryKeys.instance.experimentalSettings,
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
|
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
|
||||||
@@ -209,13 +239,16 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||||||
?? workspace
|
?? workspace
|
||||||
?? null;
|
?? null;
|
||||||
|
|
||||||
const currentSelection = shouldPresentExistingWorkspaceSelection(issue)
|
const configuredSelection = shouldPresentExistingWorkspaceSelection(issue)
|
||||||
? "reuse_existing"
|
? "reuse_existing"
|
||||||
: (
|
: (
|
||||||
issue.executionWorkspacePreference
|
issue.executionWorkspacePreference
|
||||||
?? issue.executionWorkspaceSettings?.mode
|
?? issue.executionWorkspaceSettings?.mode
|
||||||
?? defaultExecutionWorkspaceModeForProject(project)
|
?? defaultExecutionWorkspaceModeForProject(project)
|
||||||
);
|
);
|
||||||
|
const currentSelection = configuredSelection === "operator_branch" || configuredSelection === "agent_default"
|
||||||
|
? "shared_workspace"
|
||||||
|
: configuredSelection;
|
||||||
|
|
||||||
const [draftSelection, setDraftSelection] = useState(currentSelection);
|
const [draftSelection, setDraftSelection] = useState(currentSelection);
|
||||||
const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? "");
|
const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? "");
|
||||||
@@ -245,24 +278,33 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||||||
|
|
||||||
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
|
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const buildWorkspaceDraftUpdate = useCallback(() => ({
|
||||||
if (!canSaveWorkspaceConfig) return;
|
executionWorkspacePreference: draftSelection,
|
||||||
onUpdate({
|
executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null,
|
||||||
executionWorkspacePreference: draftSelection,
|
executionWorkspaceSettings: {
|
||||||
executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null,
|
mode:
|
||||||
executionWorkspaceSettings: {
|
draftSelection === "reuse_existing"
|
||||||
mode:
|
? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode)
|
||||||
draftSelection === "reuse_existing"
|
: draftSelection,
|
||||||
? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode)
|
},
|
||||||
: draftSelection,
|
}), [
|
||||||
},
|
|
||||||
});
|
|
||||||
setEditing(false);
|
|
||||||
}, [
|
|
||||||
canSaveWorkspaceConfig,
|
|
||||||
configuredReusableWorkspace?.mode,
|
configuredReusableWorkspace?.mode,
|
||||||
draftExecutionWorkspaceId,
|
draftExecutionWorkspaceId,
|
||||||
draftSelection,
|
draftSelection,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onDraftChange) return;
|
||||||
|
onDraftChange(buildWorkspaceDraftUpdate(), { canSave: canSaveWorkspaceConfig });
|
||||||
|
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, onDraftChange]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!canSaveWorkspaceConfig) return;
|
||||||
|
onUpdate(buildWorkspaceDraftUpdate());
|
||||||
|
setEditing(false);
|
||||||
|
}, [
|
||||||
|
buildWorkspaceDraftUpdate,
|
||||||
|
canSaveWorkspaceConfig,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -274,6 +316,8 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||||||
|
|
||||||
if (!policyEnabled || !project) return null;
|
if (!policyEnabled || !project) return null;
|
||||||
|
|
||||||
|
const showEditingControls = livePreview || editing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
@@ -286,7 +330,7 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||||||
{workspace ? statusBadge(workspace.status) : statusBadge("idle")}
|
{workspace ? statusBadge(workspace.status) : statusBadge("idle")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{editing ? (
|
{!livePreview && editing ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -305,7 +349,7 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : !livePreview ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -314,12 +358,12 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3 mr-1" />Edit
|
<Pencil className="h-3 w-3 mr-1" />Edit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Read-only info */}
|
{/* Read-only info */}
|
||||||
{!editing && (
|
{!showEditingControls && (
|
||||||
<div className="space-y-1.5 text-xs">
|
<div className="space-y-1.5 text-xs">
|
||||||
{workspace?.branchName && (
|
{workspace?.branchName && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -377,7 +421,7 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Editing controls */}
|
{/* Editing controls */}
|
||||||
{editing && (
|
{showEditingControls && (
|
||||||
<div className="space-y-2 pt-1">
|
<div className="space-y-2 pt-1">
|
||||||
<select
|
<select
|
||||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||||
|
|||||||
55
ui/src/components/IssuesQuicklook.tsx
Normal file
55
ui/src/components/IssuesQuicklook.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||||
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
|
|
||||||
|
interface IssuesQuicklookProps {
|
||||||
|
issue: Issue;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
asChild
|
||||||
|
onMouseEnter={() => setOpen(true)}
|
||||||
|
onMouseLeave={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-64 p-3"
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
onMouseEnter={() => setOpen(true)}
|
||||||
|
onMouseLeave={() => setOpen(false)}
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
|
||||||
|
<Link
|
||||||
|
to={createIssueDetailPath(issue.identifier ?? issue.id)}
|
||||||
|
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
|
||||||
|
>
|
||||||
|
{issue.title}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{issue.status.replace(/_/g, " ")}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{timeAgo(new Date(issue.updatedAt))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { MobileBottomNav } from "./MobileBottomNav";
|
|||||||
import { WorktreeBanner } from "./WorktreeBanner";
|
import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
import { DevRestartBanner } from "./DevRestartBanner";
|
import { DevRestartBanner } from "./DevRestartBanner";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
|
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
@@ -24,6 +25,7 @@ import { useTheme } from "../context/ThemeContext";
|
|||||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||||
import { healthApi } from "../api/health";
|
import { healthApi } from "../api/health";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
||||||
import {
|
import {
|
||||||
DEFAULT_INSTANCE_SETTINGS_PATH,
|
DEFAULT_INSTANCE_SETTINGS_PATH,
|
||||||
@@ -85,6 +87,10 @@ export function Layout() {
|
|||||||
},
|
},
|
||||||
refetchIntervalInBackground: true,
|
refetchIntervalInBackground: true,
|
||||||
});
|
});
|
||||||
|
const keyboardShortcutsEnabled = useQuery({
|
||||||
|
queryKey: queryKeys.instance.generalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||||
|
}).data?.keyboardShortcuts === true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (companiesLoading || onboardingTriggered.current) return;
|
if (companiesLoading || onboardingTriggered.current) return;
|
||||||
@@ -141,6 +147,7 @@ export function Layout() {
|
|||||||
useCompanyPageMemory();
|
useCompanyPageMemory();
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
|
enabled: keyboardShortcutsEnabled,
|
||||||
onNewIssue: () => openNewIssue(),
|
onNewIssue: () => openNewIssue(),
|
||||||
onToggleSidebar: toggleSidebar,
|
onToggleSidebar: toggleSidebar,
|
||||||
onTogglePanel: togglePanel,
|
onTogglePanel: togglePanel,
|
||||||
@@ -259,12 +266,13 @@ export function Layout() {
|
|||||||
}, [location.hash, location.pathname, location.search]);
|
}, [location.hash, location.pathname, location.search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
|
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
|
||||||
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
|
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#main-content"
|
href="#main-content"
|
||||||
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
@@ -436,6 +444,7 @@ export function Layout() {
|
|||||||
<NewGoalDialog />
|
<NewGoalDialog />
|
||||||
<NewAgentDialog />
|
<NewAgentDialog />
|
||||||
<ToastViewport />
|
<ToastViewport />
|
||||||
</div>
|
</div>
|
||||||
|
</GeneralSettingsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ export function NewIssueDialog() {
|
|||||||
queryKey: queryKeys.instance.experimentalSettings,
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
enabled: newIssueOpen,
|
enabled: newIssueOpen,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
const activeProjects = useMemo(
|
const activeProjects = useMemo(
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||||||
const { data: experimentalSettings } = useQuery({
|
const { data: experimentalSettings } = useQuery({
|
||||||
queryKey: queryKeys.instance.experimentalSettings,
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const linkedGoalIds = project.goalIds.length > 0
|
const linkedGoalIds = project.goalIds.length > 0
|
||||||
|
|||||||
136
ui/src/components/RoutineRunVariablesDialog.test.tsx
Normal file
136
ui/src/components/RoutineRunVariablesDialog.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { Project } from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
|
||||||
|
|
||||||
|
let issueWorkspaceDraftCalls = 0;
|
||||||
|
|
||||||
|
vi.mock("../api/instanceSettings", () => ({
|
||||||
|
instanceSettingsApi: {
|
||||||
|
getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: true })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./IssueWorkspaceCard", async () => {
|
||||||
|
const React = await import("react");
|
||||||
|
|
||||||
|
return {
|
||||||
|
IssueWorkspaceCard: ({
|
||||||
|
onDraftChange,
|
||||||
|
}: {
|
||||||
|
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void;
|
||||||
|
}) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
issueWorkspaceDraftCalls += 1;
|
||||||
|
if (issueWorkspaceDraftCalls > 20) {
|
||||||
|
throw new Error("IssueWorkspaceCard onDraftChange looped");
|
||||||
|
}
|
||||||
|
onDraftChange?.({
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: "shared_workspace",
|
||||||
|
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||||
|
}, { canSave: true });
|
||||||
|
}, [onDraftChange]);
|
||||||
|
|
||||||
|
return <div data-testid="workspace-card">Workspace card</div>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function createProject(): Project {
|
||||||
|
return {
|
||||||
|
id: "project-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
urlKey: "workspace-project",
|
||||||
|
goalId: null,
|
||||||
|
goalIds: [],
|
||||||
|
goals: [],
|
||||||
|
name: "Workspace project",
|
||||||
|
description: null,
|
||||||
|
status: "in_progress",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: "#22c55e",
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
allowIssueOverride: true,
|
||||||
|
},
|
||||||
|
codebase: {
|
||||||
|
workspaceId: null,
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
repoName: null,
|
||||||
|
localFolder: null,
|
||||||
|
managedFolder: "/tmp/paperclip/project-1",
|
||||||
|
effectiveLocalFolder: "/tmp/paperclip/project-1",
|
||||||
|
origin: "managed_checkout",
|
||||||
|
},
|
||||||
|
workspaces: [],
|
||||||
|
primaryWorkspace: null,
|
||||||
|
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RoutineRunVariablesDialog", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
issueWorkspaceDraftCalls = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not loop when the workspace card reports the same draft repeatedly", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RoutineRunVariablesDialog
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
companyId="company-1"
|
||||||
|
project={createProject()}
|
||||||
|
variables={[]}
|
||||||
|
isPending={false}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
|
||||||
|
expect(document.body.textContent).toContain("Run routine");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
323
ui/src/components/RoutineRunVariablesDialog.tsx
Normal file
323
ui/src/components/RoutineRunVariablesDialog.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
function buildInitialValues(variables: RoutineVariable[]) {
|
||||||
|
return Object.fromEntries(variables.map((variable) => [variable.name, variable.defaultValue ?? ""]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultProjectWorkspaceIdForProject(project: Project | null | undefined) {
|
||||||
|
if (!project) return null;
|
||||||
|
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
||||||
|
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
|
||||||
|
?? project.workspaces?.[0]?.id
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultExecutionWorkspaceModeForProject(project: Project | null | undefined) {
|
||||||
|
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
|
||||||
|
if (
|
||||||
|
defaultMode === "isolated_workspace" ||
|
||||||
|
defaultMode === "operator_branch" ||
|
||||||
|
defaultMode === "adapter_default"
|
||||||
|
) {
|
||||||
|
return defaultMode === "adapter_default" ? "agent_default" : defaultMode;
|
||||||
|
}
|
||||||
|
return "shared_workspace";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialWorkspaceConfig(project: Project | null | undefined) {
|
||||||
|
const defaultMode = defaultExecutionWorkspaceModeForProject(project);
|
||||||
|
return {
|
||||||
|
executionWorkspaceId: null as string | null,
|
||||||
|
executionWorkspacePreference: defaultMode,
|
||||||
|
executionWorkspaceSettings: { mode: defaultMode } as IssueExecutionWorkspaceSettings,
|
||||||
|
projectWorkspaceId: defaultProjectWorkspaceIdForProject(project),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceConfigEquals(
|
||||||
|
a: ReturnType<typeof buildInitialWorkspaceConfig>,
|
||||||
|
b: ReturnType<typeof buildInitialWorkspaceConfig>,
|
||||||
|
) {
|
||||||
|
return a.executionWorkspaceId === b.executionWorkspaceId
|
||||||
|
&& a.executionWorkspacePreference === b.executionWorkspacePreference
|
||||||
|
&& a.projectWorkspaceId === b.projectWorkspaceId
|
||||||
|
&& JSON.stringify(a.executionWorkspaceSettings ?? null) === JSON.stringify(b.executionWorkspaceSettings ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWorkspaceDraft(
|
||||||
|
current: ReturnType<typeof buildInitialWorkspaceConfig>,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
executionWorkspaceId: (data.executionWorkspaceId as string | null | undefined) ?? null,
|
||||||
|
executionWorkspacePreference:
|
||||||
|
(data.executionWorkspacePreference as string | null | undefined)
|
||||||
|
?? current.executionWorkspacePreference,
|
||||||
|
executionWorkspaceSettings:
|
||||||
|
(data.executionWorkspaceSettings as IssueExecutionWorkspaceSettings | null | undefined)
|
||||||
|
?? current.executionWorkspaceSettings,
|
||||||
|
};
|
||||||
|
return workspaceConfigEquals(current, next) ? current : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingRequiredValue(value: unknown) {
|
||||||
|
return value == null || (typeof value === "string" && value.trim().length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsRoutineRunWorkspaceSelection(
|
||||||
|
project: Project | null | undefined,
|
||||||
|
isolatedWorkspacesEnabled: boolean,
|
||||||
|
) {
|
||||||
|
return isolatedWorkspacesEnabled && Boolean(project?.executionWorkspacePolicy?.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routineRunNeedsConfiguration(input: {
|
||||||
|
variables: RoutineVariable[];
|
||||||
|
project: Project | null | undefined;
|
||||||
|
isolatedWorkspacesEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
return input.variables.length > 0
|
||||||
|
|| supportsRoutineRunWorkspaceSelection(input.project, input.isolatedWorkspacesEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutineRunDialogSubmitData {
|
||||||
|
variables?: Record<string, string | number | boolean>;
|
||||||
|
executionWorkspaceId?: string | null;
|
||||||
|
executionWorkspacePreference?: string | null;
|
||||||
|
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutineRunVariablesDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
companyId,
|
||||||
|
project,
|
||||||
|
variables,
|
||||||
|
isPending,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
companyId: string | null | undefined;
|
||||||
|
project: Project | null | undefined;
|
||||||
|
variables: RoutineVariable[];
|
||||||
|
isPending: boolean;
|
||||||
|
onSubmit: (data: RoutineRunDialogSubmitData) => void;
|
||||||
|
}) {
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
|
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(project));
|
||||||
|
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
|
||||||
|
|
||||||
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection(
|
||||||
|
project,
|
||||||
|
experimentalSettings?.enableIsolatedWorkspaces === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setValues(buildInitialValues(variables));
|
||||||
|
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
||||||
|
setWorkspaceConfigValid(true);
|
||||||
|
}, [open, project, variables]);
|
||||||
|
|
||||||
|
const missingRequired = useMemo(
|
||||||
|
() =>
|
||||||
|
variables
|
||||||
|
.filter((variable) => variable.required)
|
||||||
|
.filter((variable) => isMissingRequiredValue(values[variable.name]))
|
||||||
|
.map((variable) => variable.label || variable.name),
|
||||||
|
[values, variables],
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspaceIssue = useMemo(() => ({
|
||||||
|
companyId: companyId ?? null,
|
||||||
|
projectId: project?.id ?? null,
|
||||||
|
projectWorkspaceId: workspaceConfig.projectWorkspaceId,
|
||||||
|
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
||||||
|
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
|
||||||
|
executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings,
|
||||||
|
currentExecutionWorkspace: null,
|
||||||
|
}), [
|
||||||
|
companyId,
|
||||||
|
project?.id,
|
||||||
|
workspaceConfig.executionWorkspaceId,
|
||||||
|
workspaceConfig.executionWorkspacePreference,
|
||||||
|
workspaceConfig.executionWorkspaceSettings,
|
||||||
|
workspaceConfig.projectWorkspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const canSubmit = missingRequired.length === 0 && (!workspaceSelectionEnabled || workspaceConfigValid);
|
||||||
|
|
||||||
|
const handleWorkspaceUpdate = useCallback((data: Record<string, unknown>) => {
|
||||||
|
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWorkspaceDraftChange = useCallback((
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
meta: { canSave: boolean },
|
||||||
|
) => {
|
||||||
|
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
||||||
|
setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Run routine</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Fill in the routine variables before starting the execution issue.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{variables.map((variable) => (
|
||||||
|
<div key={variable.name} className="space-y-1.5">
|
||||||
|
<Label className="text-xs">
|
||||||
|
{variable.label || variable.name}
|
||||||
|
{variable.required ? " *" : ""}
|
||||||
|
</Label>
|
||||||
|
{variable.type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
value={typeof values[variable.name] === "string" ? values[variable.name] as string : ""}
|
||||||
|
onChange={(event) => setValues((current) => ({ ...current, [variable.name]: event.target.value }))}
|
||||||
|
/>
|
||||||
|
) : variable.type === "boolean" ? (
|
||||||
|
<Select
|
||||||
|
value={values[variable.name] === true ? "true" : values[variable.name] === false ? "false" : "__unset__"}
|
||||||
|
onValueChange={(next) => setValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[variable.name]: next === "__unset__" ? "" : next === "true",
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__unset__">No value</SelectItem>
|
||||||
|
<SelectItem value="true">True</SelectItem>
|
||||||
|
<SelectItem value="false">False</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : variable.type === "select" ? (
|
||||||
|
<Select
|
||||||
|
value={typeof values[variable.name] === "string" && values[variable.name] ? values[variable.name] as string : "__unset__"}
|
||||||
|
onValueChange={(next) => setValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[variable.name]: next === "__unset__" ? "" : next,
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choose a value" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__unset__">No value</SelectItem>
|
||||||
|
{variable.options.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>{option}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={variable.type === "number" ? "number" : "text"}
|
||||||
|
value={values[variable.name] == null ? "" : String(values[variable.name])}
|
||||||
|
onChange={(event) => setValues((current) => ({ ...current, [variable.name]: event.target.value }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{workspaceSelectionEnabled && project && companyId ? (
|
||||||
|
<IssueWorkspaceCard
|
||||||
|
key={`${open ? "open" : "closed"}:${project.id}`}
|
||||||
|
issue={workspaceIssue}
|
||||||
|
project={project}
|
||||||
|
initialEditing
|
||||||
|
livePreview
|
||||||
|
onUpdate={handleWorkspaceUpdate}
|
||||||
|
onDraftChange={handleWorkspaceDraftChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter showCloseButton={false}>
|
||||||
|
{missingRequired.length > 0 ? (
|
||||||
|
<p className="mr-auto text-xs text-amber-600">
|
||||||
|
Missing: {missingRequired.join(", ")}
|
||||||
|
</p>
|
||||||
|
) : workspaceSelectionEnabled && !workspaceConfigValid ? (
|
||||||
|
<p className="mr-auto text-xs text-amber-600">
|
||||||
|
Choose an existing workspace before running.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<span className="mr-auto" />
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const nextVariables: Record<string, string | number | boolean> = {};
|
||||||
|
for (const variable of variables) {
|
||||||
|
const rawValue = values[variable.name];
|
||||||
|
if (isMissingRequiredValue(rawValue)) continue;
|
||||||
|
if (variable.type === "number") {
|
||||||
|
nextVariables[variable.name] = Number(rawValue);
|
||||||
|
} else if (variable.type === "boolean") {
|
||||||
|
nextVariables[variable.name] = rawValue === true;
|
||||||
|
} else {
|
||||||
|
nextVariables[variable.name] = String(rawValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSubmit({
|
||||||
|
variables: nextVariables,
|
||||||
|
...(workspaceSelectionEnabled
|
||||||
|
? {
|
||||||
|
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
||||||
|
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
|
||||||
|
executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isPending || !canSubmit}
|
||||||
|
>
|
||||||
|
{isPending ? "Running..." : "Run routine"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
ui/src/components/RoutineVariablesEditor.tsx
Normal file
232
ui/src/components/RoutineVariablesEditor.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
const variableTypes: RoutineVariable["type"][] = ["text", "textarea", "number", "boolean", "select"];
|
||||||
|
|
||||||
|
function serializeVariables(value: RoutineVariable[]) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSelectOptions(value: string) {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVariableList(
|
||||||
|
variables: RoutineVariable[],
|
||||||
|
name: string,
|
||||||
|
mutate: (variable: RoutineVariable) => RoutineVariable,
|
||||||
|
) {
|
||||||
|
return variables.map((variable) => (variable.name === name ? mutate(variable) : variable));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutineVariablesEditor({
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
description: string;
|
||||||
|
value: RoutineVariable[];
|
||||||
|
onChange: (value: RoutineVariable[]) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const syncedVariables = useMemo(
|
||||||
|
() => syncRoutineVariablesWithTemplate(description, value),
|
||||||
|
[description, value],
|
||||||
|
);
|
||||||
|
const syncedSignature = serializeVariables(syncedVariables);
|
||||||
|
const currentSignature = serializeVariables(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (syncedSignature !== currentSignature) {
|
||||||
|
onChange(syncedVariables);
|
||||||
|
}
|
||||||
|
}, [currentSignature, onChange, syncedSignature, syncedVariables]);
|
||||||
|
|
||||||
|
if (syncedVariables.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-border/70 px-3 py-2 text-left">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Variables</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Detected from `{"{{name}}"}` placeholders in the routine instructions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-3 pt-3">
|
||||||
|
{syncedVariables.map((variable) => (
|
||||||
|
<div key={variable.name} className="rounded-lg border border-border/70 p-4">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{`{{${variable.name}}}`}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Prompt the user for this value before each manual run.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Label</Label>
|
||||||
|
<Input
|
||||||
|
value={variable.label ?? ""}
|
||||||
|
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||||
|
...current,
|
||||||
|
label: event.target.value || null,
|
||||||
|
})))}
|
||||||
|
placeholder={variable.name.replaceAll("_", " ")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Type</Label>
|
||||||
|
<Select
|
||||||
|
value={variable.type}
|
||||||
|
onValueChange={(type) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||||
|
...current,
|
||||||
|
type: type as RoutineVariable["type"],
|
||||||
|
defaultValue: type === "boolean" ? null : current.defaultValue,
|
||||||
|
options: type === "select" ? current.options : [],
|
||||||
|
})))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{variableTypes.map((type) => (
|
||||||
|
<SelectItem key={type} value={type}>{type}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 md:col-span-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Label className="text-xs">Default value</Label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={variable.required}
|
||||||
|
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||||
|
...current,
|
||||||
|
required: event.target.checked,
|
||||||
|
})))}
|
||||||
|
/>
|
||||||
|
Required
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{variable.type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={variable.defaultValue == null ? "" : String(variable.defaultValue)}
|
||||||
|
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||||
|
...current,
|
||||||
|
defaultValue: event.target.value || null,
|
||||||
|
})))}
|
||||||
|
/>
|
||||||
|
) : variable.type === "boolean" ? (
|
||||||
|
<Select
|
||||||
|
value={variable.defaultValue === true ? "true" : variable.defaultValue === false ? "false" : "__unset__"}
|
||||||
|
onValueChange={(next) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||||
|
...current,
|
||||||
|
defaultValue: next === "__unset__" ? null : next === "true",
|
||||||
|
})))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__unset__">No default</SelectItem>
|
||||||
|
<SelectItem value="true">True</SelectItem>
|
||||||
|
<SelectItem value="false">False</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : variable.type === "select" ? (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Options</Label>
|
||||||
|
<Input
|
||||||
|
value={variable.options.join(", ")}
|
||||||
|
onChange={(event) => {
|
||||||
|
const options = parseSelectOptions(event.target.value);
|
||||||
|
onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||||
|
...current,
|
||||||
|
options,
|
||||||
|
defaultValue:
|
||||||
|
typeof current.defaultValue === "string" && options.includes(current.defaultValue)
|
||||||
|
? current.defaultValue
|
||||||
|
: null,
|
||||||
|
})));
|
||||||
|
}}
|
||||||
|
placeholder="high, medium, low"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Default option</Label>
|
||||||
|
<Select
|
||||||
|
value={typeof variable.defaultValue === "string" ? variable.defaultValue : "__unset__"}
|
||||||
|
onValueChange={(next) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||||
|
...current,
|
||||||
|
defaultValue: next === "__unset__" ? null : next,
|
||||||
|
})))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="No default" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__unset__">No default</SelectItem>
|
||||||
|
{variable.options.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>{option}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={variable.type === "number" ? "number" : "text"}
|
||||||
|
value={variable.defaultValue == null ? "" : String(variable.defaultValue)}
|
||||||
|
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||||
|
...current,
|
||||||
|
defaultValue: event.target.value || null,
|
||||||
|
})))}
|
||||||
|
placeholder={variable.type === "number" ? "42" : "Default value"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutineVariablesHint() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
Use `{"{{variable_name}}"}` placeholders in the instructions to prompt for inputs when the routine runs.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
ui/src/context/GeneralSettingsContext.tsx
Normal file
28
ui/src/context/GeneralSettingsContext.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export interface GeneralSettingsContextValue {
|
||||||
|
keyboardShortcutsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GeneralSettingsContext = createContext<GeneralSettingsContextValue>({
|
||||||
|
keyboardShortcutsEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function GeneralSettingsProvider({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: GeneralSettingsContextValue;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<GeneralSettingsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</GeneralSettingsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGeneralSettings() {
|
||||||
|
return useContext(GeneralSettingsContext);
|
||||||
|
}
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||||
|
|
||||||
interface ShortcutHandlers {
|
interface ShortcutHandlers {
|
||||||
|
enabled?: boolean;
|
||||||
onNewIssue?: () => void;
|
onNewIssue?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
onTogglePanel?: () => void;
|
onTogglePanel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) {
|
export function useKeyboardShortcuts({
|
||||||
|
enabled = true,
|
||||||
|
onNewIssue,
|
||||||
|
onToggleSidebar,
|
||||||
|
onTogglePanel,
|
||||||
|
}: ShortcutHandlers) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
// Don't fire shortcuts when typing in inputs
|
// Don't fire shortcuts when typing in inputs
|
||||||
const target = e.target as HTMLElement;
|
if (isKeyboardShortcutTextInputTarget(e.target)) {
|
||||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,5 +44,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
|
|||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onNewIssue, onToggleSidebar, onTogglePanel]);
|
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,13 +193,24 @@
|
|||||||
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
/* Light mode scrollbar on hover */
|
||||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||||
background: oklch(0.205 0 0) !important;
|
background: oklch(0.92 0 0) !important;
|
||||||
}
|
}
|
||||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||||
background: oklch(0.4 0 0) !important;
|
background: oklch(0.7 0 0) !important;
|
||||||
}
|
}
|
||||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
|
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(0.6 0 0) !important;
|
||||||
|
}
|
||||||
|
/* Dark mode scrollbar on hover */
|
||||||
|
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||||
|
background: oklch(0.205 0 0) !important;
|
||||||
|
}
|
||||||
|
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(0.4 0 0) !important;
|
||||||
|
}
|
||||||
|
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
|
||||||
background: oklch(0.5 0 0) !important;
|
background: oklch(0.5 0 0) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type {
|
||||||
|
Approval,
|
||||||
|
DashboardSummary,
|
||||||
|
ExecutionWorkspace,
|
||||||
|
HeartbeatRun,
|
||||||
|
Issue,
|
||||||
|
JoinRequest,
|
||||||
|
ProjectWorkspace,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
|
getAvailableInboxIssueColumns,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
getInboxWorkItems,
|
getInboxWorkItems,
|
||||||
getInboxKeyboardSelectionIndex,
|
getInboxKeyboardSelectionIndex,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
getUnreadTouchedIssues,
|
getUnreadTouchedIssues,
|
||||||
isMineInboxTab,
|
isMineInboxTab,
|
||||||
|
loadInboxIssueColumns,
|
||||||
loadLastInboxTab,
|
loadLastInboxTab,
|
||||||
|
normalizeInboxIssueColumns,
|
||||||
RECENT_ISSUES_LIMIT,
|
RECENT_ISSUES_LIMIT,
|
||||||
|
resolveIssueWorkspaceName,
|
||||||
resolveInboxSelectionIndex,
|
resolveInboxSelectionIndex,
|
||||||
|
saveInboxIssueColumns,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
shouldShowInboxSection,
|
shouldShowInboxSection,
|
||||||
} from "./inbox";
|
} from "./inbox";
|
||||||
@@ -170,6 +184,63 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeProjectWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWorkspace {
|
||||||
|
return {
|
||||||
|
id: "project-workspace-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Primary workspace",
|
||||||
|
sourceType: "local_path",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
visibility: "default",
|
||||||
|
setupCommand: null,
|
||||||
|
cleanupCommand: null,
|
||||||
|
remoteProvider: null,
|
||||||
|
remoteWorkspaceRef: null,
|
||||||
|
sharedWorkspaceKey: null,
|
||||||
|
metadata: null,
|
||||||
|
runtimeConfig: null,
|
||||||
|
isPrimary: true,
|
||||||
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
|
||||||
|
return {
|
||||||
|
id: "execution-workspace-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "project-workspace-1",
|
||||||
|
sourceIssueId: "issue-1",
|
||||||
|
mode: "isolated_workspace",
|
||||||
|
strategyType: "git_worktree",
|
||||||
|
name: "PAP-1 branch",
|
||||||
|
status: "active",
|
||||||
|
cwd: "/tmp/project/worktree",
|
||||||
|
repoUrl: null,
|
||||||
|
baseRef: null,
|
||||||
|
branchName: "pap-1",
|
||||||
|
providerType: "git_worktree",
|
||||||
|
providerRef: null,
|
||||||
|
derivedFromExecutionWorkspaceId: null,
|
||||||
|
lastUsedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
openedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
closedAt: null,
|
||||||
|
cleanupEligibleAt: null,
|
||||||
|
cleanupReason: null,
|
||||||
|
config: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const dashboard: DashboardSummary = {
|
const dashboard: DashboardSummary = {
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
agents: {
|
agents: {
|
||||||
@@ -314,6 +385,16 @@ describe("inbox helpers", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sorts touched issues by latest external comment timestamp", () => {
|
||||||
|
const newerIssue = makeIssue("1", true);
|
||||||
|
newerIssue.lastExternalCommentAt = new Date("2026-03-11T05:00:00.000Z");
|
||||||
|
|
||||||
|
const olderIssue = makeIssue("2", true);
|
||||||
|
olderIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||||
|
|
||||||
|
expect(getRecentTouchedIssues([olderIssue, newerIssue]).map((issue) => issue.id)).toEqual(["1", "2"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("mixes join requests into the inbox feed by most recent activity", () => {
|
it("mixes join requests into the inbox feed by most recent activity", () => {
|
||||||
const issue = makeIssue("1", true);
|
const issue = makeIssue("1", true);
|
||||||
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||||
@@ -419,6 +500,116 @@ describe("inbox helpers", () => {
|
|||||||
expect(loadLastInboxTab()).toBe("all");
|
expect(loadLastInboxTab()).toBe("all");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults issue columns to the current inbox layout", () => {
|
||||||
|
expect(loadInboxIssueColumns()).toEqual(DEFAULT_INBOX_ISSUE_COLUMNS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes saved issue columns to valid values in canonical order", () => {
|
||||||
|
saveInboxIssueColumns(["labels", "updated", "status", "workspace", "labels", "assignee"]);
|
||||||
|
|
||||||
|
expect(loadInboxIssueColumns()).toEqual(["status", "assignee", "workspace", "labels", "updated"]);
|
||||||
|
expect(normalizeInboxIssueColumns(["project", "workspace", "wat", "id"])).toEqual(["id", "project", "workspace"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the workspace column option unless isolated workspaces are enabled", () => {
|
||||||
|
expect(getAvailableInboxIssueColumns(false)).toEqual(["status", "id", "assignee", "project", "labels", "updated"]);
|
||||||
|
expect(getAvailableInboxIssueColumns(true)).toEqual([
|
||||||
|
"status",
|
||||||
|
"id",
|
||||||
|
"assignee",
|
||||||
|
"project",
|
||||||
|
"workspace",
|
||||||
|
"labels",
|
||||||
|
"updated",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows hiding every optional issue column down to the title-only view", () => {
|
||||||
|
saveInboxIssueColumns([]);
|
||||||
|
expect(loadInboxIssueColumns()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows explicit workspace names but leaves the default workspace blank", () => {
|
||||||
|
const issue = makeIssue("1", true);
|
||||||
|
issue.projectId = "project-1";
|
||||||
|
issue.projectWorkspaceId = "project-workspace-1";
|
||||||
|
issue.executionWorkspaceId = "execution-workspace-1";
|
||||||
|
|
||||||
|
const executionWorkspace = makeExecutionWorkspace();
|
||||||
|
const defaultWorkspace = makeProjectWorkspace();
|
||||||
|
const secondaryWorkspace = makeProjectWorkspace({
|
||||||
|
id: "project-workspace-2",
|
||||||
|
name: "Secondary workspace",
|
||||||
|
isPrimary: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveIssueWorkspaceName(issue, {
|
||||||
|
executionWorkspaceById: new Map([[executionWorkspace.id, executionWorkspace]]),
|
||||||
|
projectWorkspaceById: new Map([
|
||||||
|
[defaultWorkspace.id, defaultWorkspace],
|
||||||
|
[secondaryWorkspace.id, secondaryWorkspace],
|
||||||
|
]),
|
||||||
|
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
|
||||||
|
}),
|
||||||
|
).toBe("PAP-1 branch");
|
||||||
|
|
||||||
|
issue.executionWorkspaceId = null;
|
||||||
|
expect(
|
||||||
|
resolveIssueWorkspaceName(issue, {
|
||||||
|
projectWorkspaceById: new Map([
|
||||||
|
[defaultWorkspace.id, defaultWorkspace],
|
||||||
|
[secondaryWorkspace.id, secondaryWorkspace],
|
||||||
|
]),
|
||||||
|
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
issue.projectWorkspaceId = secondaryWorkspace.id;
|
||||||
|
expect(
|
||||||
|
resolveIssueWorkspaceName(issue, {
|
||||||
|
projectWorkspaceById: new Map([
|
||||||
|
[defaultWorkspace.id, defaultWorkspace],
|
||||||
|
[secondaryWorkspace.id, secondaryWorkspace],
|
||||||
|
]),
|
||||||
|
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
|
||||||
|
}),
|
||||||
|
).toBe("Secondary workspace");
|
||||||
|
|
||||||
|
issue.projectWorkspaceId = null;
|
||||||
|
expect(
|
||||||
|
resolveIssueWorkspaceName(issue, {
|
||||||
|
projectWorkspaceById: new Map([
|
||||||
|
[defaultWorkspace.id, defaultWorkspace],
|
||||||
|
[secondaryWorkspace.id, secondaryWorkspace],
|
||||||
|
]),
|
||||||
|
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
issue.executionWorkspaceId = "execution-workspace-shared-default";
|
||||||
|
issue.projectWorkspaceId = defaultWorkspace.id;
|
||||||
|
expect(
|
||||||
|
resolveIssueWorkspaceName(issue, {
|
||||||
|
executionWorkspaceById: new Map([[
|
||||||
|
issue.executionWorkspaceId,
|
||||||
|
makeExecutionWorkspace({
|
||||||
|
id: issue.executionWorkspaceId,
|
||||||
|
mode: "shared_workspace",
|
||||||
|
strategyType: "project_primary",
|
||||||
|
projectWorkspaceId: defaultWorkspace.id,
|
||||||
|
name: "PAP-1067",
|
||||||
|
}),
|
||||||
|
]]),
|
||||||
|
projectWorkspaceById: new Map([
|
||||||
|
[defaultWorkspace.id, defaultWorkspace],
|
||||||
|
[secondaryWorkspace.id, secondaryWorkspace],
|
||||||
|
]),
|
||||||
|
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("maps legacy new-tab storage to mine", () => {
|
it("maps legacy new-tab storage to mine", () => {
|
||||||
localStorage.setItem("paperclip:inbox:last-tab", "new");
|
localStorage.setItem("paperclip:inbox:last-tab", "new");
|
||||||
expect(loadLastInboxTab()).toBe("mine");
|
expect(loadLastInboxTab()).toBe("mine");
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import type {
|
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
Approval,
|
|
||||||
DashboardSummary,
|
|
||||||
HeartbeatRun,
|
|
||||||
Issue,
|
|
||||||
JoinRequest,
|
|
||||||
} from "@paperclipai/shared";
|
|
||||||
|
|
||||||
export const RECENT_ISSUES_LIMIT = 100;
|
export const RECENT_ISSUES_LIMIT = 100;
|
||||||
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||||
@@ -12,8 +6,12 @@ export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_reques
|
|||||||
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
||||||
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
|
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
|
||||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||||
|
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
||||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||||
|
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "labels", "updated"] as const;
|
||||||
|
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
|
||||||
|
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
|
||||||
export type InboxWorkItem =
|
export type InboxWorkItem =
|
||||||
| {
|
| {
|
||||||
kind: "issue";
|
kind: "issue";
|
||||||
@@ -79,6 +77,80 @@ export function saveReadInboxItems(ids: Set<string>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeInboxIssueColumns(columns: Iterable<string | InboxIssueColumn>): InboxIssueColumn[] {
|
||||||
|
const selected = new Set(columns);
|
||||||
|
return inboxIssueColumns.filter((column) => selected.has(column));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableInboxIssueColumns(enableWorkspaceColumn: boolean): InboxIssueColumn[] {
|
||||||
|
if (enableWorkspaceColumn) return [...inboxIssueColumns];
|
||||||
|
return inboxIssueColumns.filter((column) => column !== "workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadInboxIssueColumns(): InboxIssueColumn[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(INBOX_ISSUE_COLUMNS_KEY);
|
||||||
|
if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||||
|
return normalizeInboxIssueColumns(parsed);
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveInboxIssueColumns(columns: InboxIssueColumn[]) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
INBOX_ISSUE_COLUMNS_KEY,
|
||||||
|
JSON.stringify(normalizeInboxIssueColumns(columns)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveIssueWorkspaceName(
|
||||||
|
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
|
||||||
|
{
|
||||||
|
executionWorkspaceById,
|
||||||
|
projectWorkspaceById,
|
||||||
|
defaultProjectWorkspaceIdByProjectId,
|
||||||
|
}: {
|
||||||
|
executionWorkspaceById?: ReadonlyMap<string, {
|
||||||
|
name: string;
|
||||||
|
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||||
|
projectWorkspaceId: string | null;
|
||||||
|
}>;
|
||||||
|
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
|
||||||
|
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
|
||||||
|
},
|
||||||
|
): string | null {
|
||||||
|
const defaultProjectWorkspaceId = issue.projectId
|
||||||
|
? defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (issue.executionWorkspaceId) {
|
||||||
|
const executionWorkspace = executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null;
|
||||||
|
const linkedProjectWorkspaceId =
|
||||||
|
executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null;
|
||||||
|
const isDefaultSharedExecutionWorkspace =
|
||||||
|
executionWorkspace?.mode === "shared_workspace" && linkedProjectWorkspaceId === defaultProjectWorkspaceId;
|
||||||
|
if (isDefaultSharedExecutionWorkspace) return null;
|
||||||
|
|
||||||
|
const workspaceName = executionWorkspace?.name;
|
||||||
|
if (workspaceName) return workspaceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issue.projectWorkspaceId) {
|
||||||
|
if (issue.projectWorkspaceId === defaultProjectWorkspaceId) return null;
|
||||||
|
const workspaceName = projectWorkspaceById?.get(issue.projectWorkspaceId)?.name;
|
||||||
|
if (workspaceName) return workspaceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadLastInboxTab(): InboxTab {
|
export function loadLastInboxTab(): InboxTab {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ describe("keyboardShortcuts helpers", () => {
|
|||||||
|
|
||||||
expect(hasBlockingShortcutDialog(root)).toBe(false);
|
expect(hasBlockingShortcutDialog(root)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("archives only the first clean y press", () => {
|
it("archives only the first clean y press", () => {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
|
|
||||||
|
|||||||
@@ -78,35 +78,37 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||||||
})) continue;
|
})) continue;
|
||||||
|
|
||||||
const existing = summaries.get(`execution:${executionWorkspace.id}`);
|
const existing = summaries.get(`execution:${executionWorkspace.id}`);
|
||||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
const nextIssues = existing?.issues ?? [];
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
nextIssues.push(issue);
|
||||||
);
|
|
||||||
|
|
||||||
summaries.set(`execution:${executionWorkspace.id}`, {
|
if (!existing) {
|
||||||
key: `execution:${executionWorkspace.id}`,
|
summaries.set(`execution:${executionWorkspace.id}`, {
|
||||||
kind: "execution_workspace",
|
key: `execution:${executionWorkspace.id}`,
|
||||||
workspaceId: executionWorkspace.id,
|
kind: "execution_workspace",
|
||||||
workspaceName: executionWorkspace.name,
|
workspaceId: executionWorkspace.id,
|
||||||
cwd: executionWorkspace.cwd ?? null,
|
workspaceName: executionWorkspace.name,
|
||||||
branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null,
|
cwd: executionWorkspace.cwd ?? null,
|
||||||
lastUpdatedAt: maxDate(
|
branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null,
|
||||||
existing?.lastUpdatedAt,
|
lastUpdatedAt: maxDate(
|
||||||
executionWorkspace.lastUsedAt,
|
executionWorkspace.lastUsedAt,
|
||||||
executionWorkspace.updatedAt,
|
executionWorkspace.updatedAt,
|
||||||
issue.updatedAt,
|
issue.updatedAt,
|
||||||
),
|
),
|
||||||
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
||||||
executionWorkspaceId: executionWorkspace.id,
|
executionWorkspaceId: executionWorkspace.id,
|
||||||
executionWorkspaceStatus: executionWorkspace.status,
|
executionWorkspaceStatus: executionWorkspace.status,
|
||||||
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
|
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
|
||||||
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||||
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||||
hasRuntimeConfig: Boolean(
|
hasRuntimeConfig: Boolean(
|
||||||
executionWorkspace.config?.workspaceRuntime
|
executionWorkspace.config?.workspaceRuntime
|
||||||
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
||||||
),
|
),
|
||||||
issues: nextIssues,
|
issues: nextIssues,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
existing.lastUpdatedAt = maxDate(existing.lastUpdatedAt, issue.updatedAt);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,27 +117,30 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||||||
if (!projectWorkspace) continue;
|
if (!projectWorkspace) continue;
|
||||||
|
|
||||||
const existing = summaries.get(`project:${projectWorkspace.id}`);
|
const existing = summaries.get(`project:${projectWorkspace.id}`);
|
||||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
const nextIssues = existing?.issues ?? [];
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
nextIssues.push(issue);
|
||||||
);
|
|
||||||
|
|
||||||
summaries.set(`project:${projectWorkspace.id}`, {
|
if (!existing) {
|
||||||
key: `project:${projectWorkspace.id}`,
|
summaries.set(`project:${projectWorkspace.id}`, {
|
||||||
kind: "project_workspace",
|
key: `project:${projectWorkspace.id}`,
|
||||||
workspaceId: projectWorkspace.id,
|
kind: "project_workspace",
|
||||||
workspaceName: projectWorkspace.name,
|
workspaceId: projectWorkspace.id,
|
||||||
cwd: projectWorkspace.cwd ?? null,
|
workspaceName: projectWorkspace.name,
|
||||||
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
cwd: projectWorkspace.cwd ?? null,
|
||||||
lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt),
|
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
||||||
projectWorkspaceId: projectWorkspace.id,
|
lastUpdatedAt: maxDate(projectWorkspace.updatedAt, issue.updatedAt),
|
||||||
executionWorkspaceId: null,
|
projectWorkspaceId: projectWorkspace.id,
|
||||||
executionWorkspaceStatus: null,
|
executionWorkspaceId: null,
|
||||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
executionWorkspaceStatus: null,
|
||||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
||||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||||
issues: nextIssues,
|
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||||
});
|
issues: nextIssues,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
existing.lastUpdatedAt = maxDate(existing.lastUpdatedAt, issue.updatedAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const projectWorkspace of input.project.workspaces) {
|
for (const projectWorkspace of input.project.workspaces) {
|
||||||
@@ -165,8 +170,17 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...summaries.values()].sort((a, b) => {
|
const result = [...summaries.values()];
|
||||||
|
// Sort issues within each summary once (instead of on every insertion)
|
||||||
|
const issueTime = (issue: Issue) => new Date(issue.updatedAt).getTime();
|
||||||
|
for (const summary of result) {
|
||||||
|
if (summary.issues.length > 1) {
|
||||||
|
summary.issues.sort((a, b) => issueTime(b) - issueTime(a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sort((a, b) => {
|
||||||
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||||
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);
|
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);
|
||||||
});
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2937,7 +2937,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb
|
|||||||
payload: resumePayload,
|
payload: resumePayload,
|
||||||
}, run.companyId);
|
}, run.companyId);
|
||||||
if (!("id" in result)) {
|
if (!("id" in result)) {
|
||||||
throw new Error("Resume request was skipped because the agent is not currently invokable.");
|
throw new Error(result.message ?? "Resume request was skipped.");
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
@@ -2969,7 +2969,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb
|
|||||||
payload: retryPayload,
|
payload: retryPayload,
|
||||||
}, run.companyId);
|
}, run.companyId);
|
||||||
if (!("id" in result)) {
|
if (!("id" in result)) {
|
||||||
throw new Error("Retry was skipped because the agent is not currently invokable.");
|
throw new Error(result.message ?? "Retry was skipped.");
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ComponentProps } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox";
|
import { FailedRunInboxRow, InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./Inbox";
|
||||||
|
|
||||||
vi.mock("@/lib/router", () => ({
|
vi.mock("@/lib/router", () => ({
|
||||||
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
||||||
@@ -148,31 +148,91 @@ describe("InboxIssueMetaLeading", () => {
|
|||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("neutralizes selected status and live accents", () => {
|
it("keeps status and live accents visible", () => {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(<InboxIssueMetaLeading issue={createIssue()} selected isLive />);
|
root.render(<InboxIssueMetaLeading issue={createIssue()} isLive />);
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
|
const statusIcon = container.querySelector('span[class*="border-blue-600"]');
|
||||||
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]');
|
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-blue-500/10"]');
|
||||||
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
|
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
|
||||||
(node) => node.textContent === "Live" && node.className.includes("text-"),
|
(node) => node.textContent === "Live" && node.className.includes("text-"),
|
||||||
);
|
);
|
||||||
const liveDot = container.querySelector('span[class*="bg-muted-foreground/70"]');
|
const liveDot = container.querySelector('span[class*="bg-blue-500"]');
|
||||||
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
|
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
|
||||||
|
|
||||||
expect(statusIcon).not.toBeNull();
|
expect(statusIcon).not.toBeNull();
|
||||||
expect(statusIcon?.className).toContain("!border-muted-foreground");
|
expect(statusIcon?.className).not.toContain("!border-muted-foreground");
|
||||||
expect(statusIcon?.className).toContain("!text-muted-foreground");
|
expect(statusIcon?.className).not.toContain("!text-muted-foreground");
|
||||||
expect(liveBadge).not.toBeNull();
|
expect(liveBadge).not.toBeNull();
|
||||||
expect(liveBadge?.className).toContain("bg-muted");
|
expect(liveBadge?.className).toContain("bg-blue-500/10");
|
||||||
expect(liveBadgeLabel).not.toBeNull();
|
expect(liveBadgeLabel).not.toBeNull();
|
||||||
expect(liveBadgeLabel?.className).toContain("text-muted-foreground");
|
expect(liveBadgeLabel?.className).toContain("text-blue-600");
|
||||||
expect(liveBadgeLabel?.className).not.toContain("text-blue-600");
|
|
||||||
expect(liveDot).not.toBeNull();
|
expect(liveDot).not.toBeNull();
|
||||||
expect(pulseRing).toBeNull();
|
expect(pulseRing).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InboxIssueTrailingColumns", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty tags cell when an issue has no labels", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<InboxIssueTrailingColumns
|
||||||
|
issue={createIssue({ labels: [], labelIds: [] })}
|
||||||
|
columns={["labels"]}
|
||||||
|
projectName={null}
|
||||||
|
projectColor={null}
|
||||||
|
workspaceName={null}
|
||||||
|
assigneeName={null}
|
||||||
|
currentUserId={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the workspace cell blank when no explicit workspace label should be shown", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<InboxIssueTrailingColumns
|
||||||
|
issue={createIssue()}
|
||||||
|
columns={["workspace"]}
|
||||||
|
projectName={null}
|
||||||
|
projectColor={null}
|
||||||
|
workspaceName={null}
|
||||||
|
assigneeName={null}
|
||||||
|
currentUserId={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|||||||
@@ -4,15 +4,25 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||||
import { approvalsApi } from "../api/approvals";
|
import { approvalsApi } from "../api/approvals";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { dashboardApi } from "../api/dashboard";
|
import { dashboardApi } from "../api/dashboard";
|
||||||
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { useGeneralSettings } from "../context/GeneralSettingsContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
import {
|
||||||
|
armIssueDetailInboxQuickArchive,
|
||||||
|
createIssueDetailLocationState,
|
||||||
|
createIssueDetailPath,
|
||||||
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
|
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { IssueRow } from "../components/IssueRow";
|
import { IssueRow } from "../components/IssueRow";
|
||||||
@@ -21,11 +31,31 @@ import { SwipeToArchive } from "../components/SwipeToArchive";
|
|||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { Identity } from "../components/Identity";
|
||||||
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
|
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
|
||||||
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -40,19 +70,29 @@ import {
|
|||||||
X,
|
X,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Columns3,
|
||||||
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
ACTIONABLE_APPROVAL_STATUSES,
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
|
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||||
|
getAvailableInboxIssueColumns,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
getInboxWorkItems,
|
getInboxWorkItems,
|
||||||
getInboxKeyboardSelectionIndex,
|
getInboxKeyboardSelectionIndex,
|
||||||
getLatestFailedRunsByAgent,
|
getLatestFailedRunsByAgent,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
isMineInboxTab,
|
isMineInboxTab,
|
||||||
|
loadInboxIssueColumns,
|
||||||
|
normalizeInboxIssueColumns,
|
||||||
|
resolveIssueWorkspaceName,
|
||||||
resolveInboxSelectionIndex,
|
resolveInboxSelectionIndex,
|
||||||
|
saveInboxIssueColumns,
|
||||||
InboxApprovalFilter,
|
InboxApprovalFilter,
|
||||||
|
type InboxIssueColumn,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
shouldShowInboxSection,
|
shouldShowInboxSection,
|
||||||
type InboxTab,
|
type InboxTab,
|
||||||
@@ -100,58 +140,69 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
|||||||
|
|
||||||
|
|
||||||
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
|
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
|
||||||
const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground";
|
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "labels", "updated"];
|
||||||
|
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
|
||||||
function getSelectedUnreadButtonClass(selected: boolean): string {
|
status: "Status",
|
||||||
return selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20";
|
id: "ID",
|
||||||
}
|
assignee: "Assignee",
|
||||||
|
project: "Project",
|
||||||
function getSelectedUnreadDotClass(selected: boolean): string {
|
workspace: "Workspace",
|
||||||
return selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400";
|
labels: "Tags",
|
||||||
}
|
updated: "Last updated",
|
||||||
|
};
|
||||||
|
const inboxIssueColumnDescriptions: Record<InboxIssueColumn, string> = {
|
||||||
|
status: "Issue state chip on the left edge.",
|
||||||
|
id: "Ticket identifier like PAP-1009.",
|
||||||
|
assignee: "Assigned agent or board user.",
|
||||||
|
project: "Linked project pill with its color.",
|
||||||
|
workspace: "Execution or project workspace used for the issue.",
|
||||||
|
labels: "Issue labels and tags.",
|
||||||
|
updated: "Latest visible activity time.",
|
||||||
|
};
|
||||||
|
|
||||||
export function InboxIssueMetaLeading({
|
export function InboxIssueMetaLeading({
|
||||||
issue,
|
issue,
|
||||||
selected,
|
|
||||||
isLive,
|
isLive,
|
||||||
|
showStatus = true,
|
||||||
|
showIdentifier = true,
|
||||||
}: {
|
}: {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
selected: boolean;
|
|
||||||
isLive: boolean;
|
isLive: boolean;
|
||||||
|
showStatus?: boolean;
|
||||||
|
showIdentifier?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="hidden shrink-0 sm:inline-flex">
|
{showStatus ? (
|
||||||
<StatusIcon
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
status={issue.status}
|
<StatusIcon status={issue.status} />
|
||||||
className={selected ? selectedInboxAccentClass : undefined}
|
</span>
|
||||||
/>
|
) : null}
|
||||||
</span>
|
{showIdentifier ? (
|
||||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
|
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
|
||||||
selected ? "bg-muted" : "bg-blue-500/10",
|
"bg-blue-500/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="relative flex h-2 w-2">
|
||||||
{!selected ? (
|
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
|
||||||
) : null}
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-2 w-2 rounded-full",
|
"relative inline-flex h-2 w-2 rounded-full",
|
||||||
selected ? "bg-muted-foreground/70" : "bg-blue-500",
|
"bg-blue-500",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden text-[11px] font-medium sm:inline",
|
"hidden text-[11px] font-medium sm:inline",
|
||||||
selected ? "text-muted-foreground" : "text-blue-600 dark:text-blue-400",
|
"text-blue-600 dark:text-blue-400",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Live
|
Live
|
||||||
@@ -162,6 +213,150 @@ export function InboxIssueMetaLeading({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function issueActivityText(issue: Issue): string {
|
||||||
|
return `Updated ${timeAgo(issue.lastExternalCommentAt ?? issue.updatedAt)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
||||||
|
return columns
|
||||||
|
.map((column) => {
|
||||||
|
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
||||||
|
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
||||||
|
if (column === "workspace") return "minmax(9rem, 12rem)";
|
||||||
|
if (column === "labels") return "minmax(8rem, 10rem)";
|
||||||
|
return "minmax(6rem, 7rem)";
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboxIssueTrailingColumns({
|
||||||
|
issue,
|
||||||
|
columns,
|
||||||
|
projectName,
|
||||||
|
projectColor,
|
||||||
|
workspaceName,
|
||||||
|
assigneeName,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
issue: Issue;
|
||||||
|
columns: InboxIssueColumn[];
|
||||||
|
projectName: string | null;
|
||||||
|
projectColor: string | null;
|
||||||
|
workspaceName: string | null;
|
||||||
|
assigneeName: string | null;
|
||||||
|
currentUserId: string | null;
|
||||||
|
}) {
|
||||||
|
const activityText = timeAgo(issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||||
|
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="grid items-center gap-2"
|
||||||
|
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
|
||||||
|
>
|
||||||
|
{columns.map((column) => {
|
||||||
|
if (column === "assignee") {
|
||||||
|
if (issue.assigneeAgentId) {
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 text-xs text-foreground">
|
||||||
|
<Identity
|
||||||
|
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
|
||||||
|
size="sm"
|
||||||
|
className="min-w-0"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issue.assigneeUserId) {
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||||
|
{userLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
Unassigned
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "project") {
|
||||||
|
if (projectName) {
|
||||||
|
const accentColor = projectColor ?? "#64748b";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={column}
|
||||||
|
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
|
||||||
|
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: accentColor }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{projectName}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
No project
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "labels") {
|
||||||
|
if ((issue.labels ?? []).length > 0) {
|
||||||
|
return (
|
||||||
|
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
|
||||||
|
{(issue.labels ?? []).slice(0, 2).map((label) => (
|
||||||
|
<span
|
||||||
|
key={label.id}
|
||||||
|
className="inline-flex min-w-0 max-w-full items-center font-medium"
|
||||||
|
style={{
|
||||||
|
color: pickTextColorForPillBg(label.color, 0.12),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{label.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(issue.labels ?? []).length > 2 ? (
|
||||||
|
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
|
||||||
|
+{(issue.labels ?? []).length - 2}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "workspace") {
|
||||||
|
if (!workspaceName) {
|
||||||
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
{workspaceName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
||||||
|
{activityText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FailedRunInboxRow({
|
export function FailedRunInboxRow({
|
||||||
run,
|
run,
|
||||||
issueById,
|
issueById,
|
||||||
@@ -211,13 +406,13 @@ export function FailedRunInboxRow({
|
|||||||
onClick={onMarkRead}
|
onClick={onMarkRead}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||||
getSelectedUnreadButtonClass(selected),
|
"hover:bg-blue-500/20",
|
||||||
)}
|
)}
|
||||||
aria-label="Mark as read"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||||
getSelectedUnreadDotClass(selected),
|
"bg-blue-600 dark:bg-blue-400",
|
||||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
@@ -367,13 +562,13 @@ function ApprovalInboxRow({
|
|||||||
onClick={onMarkRead}
|
onClick={onMarkRead}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||||
getSelectedUnreadButtonClass(selected),
|
"hover:bg-blue-500/20",
|
||||||
)}
|
)}
|
||||||
aria-label="Mark as read"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||||
getSelectedUnreadDotClass(selected),
|
"bg-blue-600 dark:bg-blue-400",
|
||||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
@@ -506,13 +701,13 @@ function JoinRequestInboxRow({
|
|||||||
onClick={onMarkRead}
|
onClick={onMarkRead}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||||
getSelectedUnreadButtonClass(selected),
|
"hover:bg-blue-500/20",
|
||||||
)}
|
)}
|
||||||
aria-label="Mark as read"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||||
getSelectedUnreadDotClass(selected),
|
"bg-blue-600 dark:bg-blue-400",
|
||||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
@@ -597,8 +792,16 @@ export function Inbox() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const { keyboardShortcutsEnabled } = useGeneralSettings();
|
||||||
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||||
|
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||||
const { dismissed, dismiss } = useDismissedInboxItems();
|
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||||
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
||||||
|
|
||||||
@@ -618,12 +821,31 @@ export function Inbox() {
|
|||||||
[location.pathname, location.search, location.hash],
|
[location.pathname, location.search, location.hash],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: projects } = useQuery({
|
||||||
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||||
|
const { data: executionWorkspaces = [] } = useQuery({
|
||||||
|
queryKey: selectedCompanyId
|
||||||
|
? queryKeys.executionWorkspaces.list(selectedCompanyId)
|
||||||
|
: ["execution-workspaces", "__disabled__"],
|
||||||
|
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId && isolatedWorkspacesEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Inbox" }]);
|
setBreadcrumbs([{ label: "Inbox" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
@@ -631,6 +853,7 @@ export function Inbox() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveLastInboxTab(tab);
|
saveLastInboxTab(tab);
|
||||||
setSelectedIndex(-1);
|
setSelectedIndex(-1);
|
||||||
|
setSearchQuery("");
|
||||||
}, [tab]);
|
}, [tab]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -731,6 +954,59 @@ export function Inbox() {
|
|||||||
for (const issue of issues ?? []) map.set(issue.id, issue);
|
for (const issue of issues ?? []) map.set(issue.id, issue);
|
||||||
return map;
|
return map;
|
||||||
}, [issues]);
|
}, [issues]);
|
||||||
|
const projectById = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string; color: string | null }>();
|
||||||
|
for (const project of projects ?? []) {
|
||||||
|
map.set(project.id, { name: project.name, color: project.color });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [projects]);
|
||||||
|
const projectWorkspaceById = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string }>();
|
||||||
|
for (const project of projects ?? []) {
|
||||||
|
for (const workspace of project.workspaces ?? []) {
|
||||||
|
map.set(workspace.id, { name: workspace.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [projects]);
|
||||||
|
const defaultProjectWorkspaceIdByProjectId = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const project of projects ?? []) {
|
||||||
|
const defaultWorkspaceId =
|
||||||
|
project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
||||||
|
?? project.primaryWorkspace?.id
|
||||||
|
?? null;
|
||||||
|
if (defaultWorkspaceId) map.set(project.id, defaultWorkspaceId);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [projects]);
|
||||||
|
const executionWorkspaceById = useMemo(() => {
|
||||||
|
const map = new Map<string, {
|
||||||
|
name: string;
|
||||||
|
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||||
|
projectWorkspaceId: string | null;
|
||||||
|
}>();
|
||||||
|
for (const workspace of executionWorkspaces) {
|
||||||
|
map.set(workspace.id, {
|
||||||
|
name: workspace.name,
|
||||||
|
mode: workspace.mode,
|
||||||
|
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [executionWorkspaces]);
|
||||||
|
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
|
||||||
|
const availableIssueColumns = useMemo(
|
||||||
|
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
|
||||||
|
[isolatedWorkspacesEnabled],
|
||||||
|
);
|
||||||
|
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
|
||||||
|
const visibleTrailingIssueColumns = useMemo(
|
||||||
|
() => trailingIssueColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
|
||||||
|
[availableIssueColumnSet, visibleIssueColumnSet],
|
||||||
|
);
|
||||||
|
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||||
|
|
||||||
const failedRuns = useMemo(
|
const failedRuns = useMemo(
|
||||||
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
|
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
|
||||||
@@ -784,10 +1060,81 @@ export function Inbox() {
|
|||||||
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab],
|
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredWorkItems = useMemo(() => {
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (!q) return workItemsToRender;
|
||||||
|
return workItemsToRender.filter((item) => {
|
||||||
|
if (item.kind === "issue") {
|
||||||
|
const issue = item.issue;
|
||||||
|
if (issue.title.toLowerCase().includes(q)) return true;
|
||||||
|
if (issue.identifier?.toLowerCase().includes(q)) return true;
|
||||||
|
if (issue.description?.toLowerCase().includes(q)) return true;
|
||||||
|
if (isolatedWorkspacesEnabled) {
|
||||||
|
const workspaceName = resolveIssueWorkspaceName(issue, {
|
||||||
|
executionWorkspaceById,
|
||||||
|
projectWorkspaceById,
|
||||||
|
defaultProjectWorkspaceIdByProjectId,
|
||||||
|
});
|
||||||
|
if (workspaceName?.toLowerCase().includes(q)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (item.kind === "approval") {
|
||||||
|
const a = item.approval;
|
||||||
|
const label = approvalLabel(a.type, a.payload as Record<string, unknown> | null);
|
||||||
|
if (label.toLowerCase().includes(q)) return true;
|
||||||
|
if (a.type.toLowerCase().includes(q)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (item.kind === "failed_run") {
|
||||||
|
const run = item.run;
|
||||||
|
const name = agentById.get(run.agentId);
|
||||||
|
if (name?.toLowerCase().includes(q)) return true;
|
||||||
|
const msg = runFailureMessage(run);
|
||||||
|
if (msg.toLowerCase().includes(q)) return true;
|
||||||
|
const issueId = readIssueIdFromRun(run);
|
||||||
|
if (issueId) {
|
||||||
|
const issue = issueById.get(issueId);
|
||||||
|
if (issue?.title.toLowerCase().includes(q)) return true;
|
||||||
|
if (issue?.identifier?.toLowerCase().includes(q)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (item.kind === "join_request") {
|
||||||
|
const jr = item.joinRequest;
|
||||||
|
if (jr.agentName?.toLowerCase().includes(q)) return true;
|
||||||
|
if (jr.capabilities?.toLowerCase().includes(q)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
workItemsToRender,
|
||||||
|
searchQuery,
|
||||||
|
agentById,
|
||||||
|
defaultProjectWorkspaceIdByProjectId,
|
||||||
|
executionWorkspaceById,
|
||||||
|
issueById,
|
||||||
|
isolatedWorkspacesEnabled,
|
||||||
|
projectWorkspaceById,
|
||||||
|
]);
|
||||||
|
|
||||||
const agentName = (id: string | null) => {
|
const agentName = (id: string | null) => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
return agentById.get(id) ?? null;
|
return agentById.get(id) ?? null;
|
||||||
};
|
};
|
||||||
|
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
|
||||||
|
const normalized = normalizeInboxIssueColumns(next);
|
||||||
|
setVisibleIssueColumns(normalized);
|
||||||
|
saveInboxIssueColumns(normalized);
|
||||||
|
}, []);
|
||||||
|
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
setIssueColumns([...visibleIssueColumns, column]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
|
||||||
|
}, [setIssueColumns, visibleIssueColumns]);
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: (id: string) => approvalsApi.approve(id),
|
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||||
@@ -858,7 +1205,7 @@ export function Inbox() {
|
|||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
if (!("id" in result)) {
|
if (!("id" in result)) {
|
||||||
throw new Error("Retry was skipped because the agent is not currently invokable.");
|
throw new Error(result.message ?? "Retry was skipped.");
|
||||||
}
|
}
|
||||||
return { newRun: result, originalRun: run };
|
return { newRun: result, originalRun: run };
|
||||||
},
|
},
|
||||||
@@ -881,6 +1228,7 @@ export function Inbox() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
||||||
|
const [showMarkAllReadConfirm, setShowMarkAllReadConfirm] = useState(false);
|
||||||
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
||||||
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
|
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
|
||||||
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
|
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
|
||||||
@@ -1017,12 +1365,12 @@ export function Inbox() {
|
|||||||
|
|
||||||
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
|
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
|
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, filteredWorkItems.length));
|
||||||
}, [workItemsToRender.length]);
|
}, [filteredWorkItems.length]);
|
||||||
|
|
||||||
// Use refs for keyboard handler to avoid stale closures
|
// Use refs for keyboard handler to avoid stale closures
|
||||||
const kbStateRef = useRef({
|
const kbStateRef = useRef({
|
||||||
workItems: workItemsToRender,
|
workItems: filteredWorkItems,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
canArchive: canArchiveFromTab,
|
canArchive: canArchiveFromTab,
|
||||||
archivingIssueIds,
|
archivingIssueIds,
|
||||||
@@ -1031,7 +1379,7 @@ export function Inbox() {
|
|||||||
readItems,
|
readItems,
|
||||||
});
|
});
|
||||||
kbStateRef.current = {
|
kbStateRef.current = {
|
||||||
workItems: workItemsToRender,
|
workItems: filteredWorkItems,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
canArchive: canArchiveFromTab,
|
canArchive: canArchiveFromTab,
|
||||||
archivingIssueIds,
|
archivingIssueIds,
|
||||||
@@ -1061,6 +1409,8 @@ export function Inbox() {
|
|||||||
|
|
||||||
// Keyboard shortcuts (mail-client style) — single stable listener using refs
|
// Keyboard shortcuts (mail-client style) — single stable listener using refs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!keyboardShortcutsEnabled) return;
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.defaultPrevented) return;
|
if (e.defaultPrevented) return;
|
||||||
|
|
||||||
@@ -1068,9 +1418,8 @@ export function Inbox() {
|
|||||||
const target = e.target;
|
const target = e.target;
|
||||||
if (
|
if (
|
||||||
!(target instanceof HTMLElement) ||
|
!(target instanceof HTMLElement) ||
|
||||||
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
|
isKeyboardShortcutTextInputTarget(target) ||
|
||||||
target.isContentEditable ||
|
hasBlockingShortcutDialog(document) ||
|
||||||
document.querySelector("[role='dialog'], [aria-modal='true']") ||
|
|
||||||
e.metaKey ||
|
e.metaKey ||
|
||||||
e.ctrlKey ||
|
e.ctrlKey ||
|
||||||
e.altKey
|
e.altKey
|
||||||
@@ -1148,7 +1497,8 @@ export function Inbox() {
|
|||||||
const item = st.workItems[st.selectedIndex];
|
const item = st.workItems[st.selectedIndex];
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
const pathId = item.issue.identifier ?? item.issue.id;
|
const pathId = item.issue.identifier ?? item.issue.id;
|
||||||
act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
|
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
||||||
|
act.navigate(createIssueDetailPath(pathId, detailState), { state: detailState });
|
||||||
} else if (item.kind === "approval") {
|
} else if (item.kind === "approval") {
|
||||||
act.navigate(`/approvals/${item.approval.id}`);
|
act.navigate(`/approvals/${item.approval.id}`);
|
||||||
} else if (item.kind === "failed_run") {
|
} else if (item.kind === "failed_run") {
|
||||||
@@ -1162,7 +1512,7 @@ export function Inbox() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [getWorkItemKey, issueLinkState]);
|
}, [getWorkItemKey, issueLinkState, keyboardShortcutsEnabled]);
|
||||||
|
|
||||||
// Scroll selected item into view
|
// Scroll selected item into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1184,7 +1534,7 @@ export function Inbox() {
|
|||||||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||||
!dismissed.has("alert:budget");
|
!dismissed.has("alert:budget");
|
||||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||||
const showWorkItemsSection = workItemsToRender.length > 0;
|
const showWorkItemsSection = filteredWorkItems.length > 0;
|
||||||
const showAlertsSection = shouldShowInboxSection({
|
const showAlertsSection = shouldShowInboxSection({
|
||||||
tab,
|
tab,
|
||||||
hasItems: hasAlerts,
|
hasItems: hasAlerts,
|
||||||
@@ -1214,7 +1564,6 @@ export function Inbox() {
|
|||||||
const unreadIssueIds = markAllReadIssues
|
const unreadIssueIds = markAllReadIssues
|
||||||
.map((issue) => issue.id);
|
.map((issue) => issue.id);
|
||||||
const canMarkAllRead = unreadIssueIds.length > 0;
|
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -1236,17 +1585,104 @@ export function Inbox() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search inbox…"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-8 w-[180px] pl-8 text-xs sm:w-[220px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Show / hide columns
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
|
||||||
|
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
|
Desktop issue rows
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
Choose which inbox columns stay visible
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{availableIssueColumns.map((column) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column}
|
||||||
|
checked={visibleIssueColumnSet.has(column)}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
onCheckedChange={(checked) => toggleIssueColumn(column, checked === true)}
|
||||||
|
className="items-start rounded-lg px-3 py-2.5 pl-8"
|
||||||
|
>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{inboxIssueColumnLabels[column]}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{inboxIssueColumnDescriptions[column]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||||
|
className="rounded-lg px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
Reset defaults
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
{canMarkAllRead && (
|
{canMarkAllRead && (
|
||||||
<Button
|
<>
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="h-8 shrink-0"
|
size="sm"
|
||||||
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
|
className="h-8 shrink-0"
|
||||||
disabled={markAllReadMutation.isPending}
|
onClick={() => setShowMarkAllReadConfirm(true)}
|
||||||
>
|
disabled={markAllReadMutation.isPending}
|
||||||
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
>
|
||||||
</Button>
|
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
||||||
|
</Button>
|
||||||
|
<Dialog open={showMarkAllReadConfirm} onOpenChange={setShowMarkAllReadConfirm}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Mark all as read?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowMarkAllReadConfirm(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowMarkAllReadConfirm(false);
|
||||||
|
markAllReadMutation.mutate(unreadIssueIds);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1297,9 +1733,11 @@ export function Inbox() {
|
|||||||
|
|
||||||
{allLoaded && visibleSections.length === 0 && (
|
{allLoaded && visibleSections.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={InboxIcon}
|
icon={searchQuery.trim() ? Search : InboxIcon}
|
||||||
message={
|
message={
|
||||||
tab === "mine"
|
searchQuery.trim()
|
||||||
|
? "No inbox items match your search."
|
||||||
|
: tab === "mine"
|
||||||
? "Inbox zero."
|
? "Inbox zero."
|
||||||
: tab === "unread"
|
: tab === "unread"
|
||||||
? "No new inbox items."
|
? "No new inbox items."
|
||||||
@@ -1315,7 +1753,7 @@ export function Inbox() {
|
|||||||
{showSeparatorBefore("work_items") && <Separator />}
|
{showSeparatorBefore("work_items") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
{workItemsToRender.flatMap((item, index) => {
|
{filteredWorkItems.flatMap((item, index) => {
|
||||||
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||||
<div
|
<div
|
||||||
key={`sel-${key}`}
|
key={`sel-${key}`}
|
||||||
@@ -1331,13 +1769,13 @@ export function Inbox() {
|
|||||||
index > 0 &&
|
index > 0 &&
|
||||||
item.timestamp > 0 &&
|
item.timestamp > 0 &&
|
||||||
item.timestamp < todayCutoff &&
|
item.timestamp < todayCutoff &&
|
||||||
workItemsToRender[index - 1].timestamp >= todayCutoff;
|
filteredWorkItems[index - 1].timestamp >= todayCutoff;
|
||||||
const elements: ReactNode[] = [];
|
const elements: ReactNode[] = [];
|
||||||
if (showTodayDivider) {
|
if (showTodayDivider) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<div key="today-divider" className="flex items-center gap-3 px-4 my-2">
|
<div key="today-divider" className="flex items-center gap-3 px-4 my-2">
|
||||||
<div className="flex-1 border-t border-border" />
|
<div className="flex-1 border-t border-zinc-600" />
|
||||||
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||||
Earlier
|
Earlier
|
||||||
</span>
|
</span>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -1458,6 +1896,7 @@ export function Inbox() {
|
|||||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||||
const isFading = fadingOutIssues.has(issue.id);
|
const isFading = fadingOutIssues.has(issue.id);
|
||||||
const isArchiving = archivingIssueIds.has(issue.id);
|
const isArchiving = archivingIssueIds.has(issue.id);
|
||||||
|
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||||
const row = (
|
const row = (
|
||||||
<IssueRow
|
<IssueRow
|
||||||
key={`issue:${issue.id}`}
|
key={`issue:${issue.id}`}
|
||||||
@@ -1472,15 +1911,12 @@ export function Inbox() {
|
|||||||
desktopMetaLeading={
|
desktopMetaLeading={
|
||||||
<InboxIssueMetaLeading
|
<InboxIssueMetaLeading
|
||||||
issue={issue}
|
issue={issue}
|
||||||
selected={isSelected}
|
|
||||||
isLive={liveIssueIds.has(issue.id)}
|
isLive={liveIssueIds.has(issue.id)}
|
||||||
|
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
||||||
|
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
mobileMeta={
|
mobileMeta={issueActivityText(issue).toLowerCase()}
|
||||||
issue.lastExternalCommentAt
|
|
||||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
|
||||||
: `updated ${timeAgo(issue.updatedAt)}`
|
|
||||||
}
|
|
||||||
unreadState={
|
unreadState={
|
||||||
isUnread ? "visible" : isFading ? "fading" : "hidden"
|
isUnread ? "visible" : isFading ? "fading" : "hidden"
|
||||||
}
|
}
|
||||||
@@ -1491,10 +1927,22 @@ export function Inbox() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
|
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
|
||||||
trailingMeta={
|
desktopTrailing={
|
||||||
issue.lastExternalCommentAt
|
visibleTrailingIssueColumns.length > 0 ? (
|
||||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
<InboxIssueTrailingColumns
|
||||||
: `updated ${timeAgo(issue.updatedAt)}`
|
issue={issue}
|
||||||
|
columns={visibleTrailingIssueColumns}
|
||||||
|
projectName={issueProject?.name ?? null}
|
||||||
|
projectColor={issueProject?.color ?? null}
|
||||||
|
workspaceName={resolveIssueWorkspaceName(issue, {
|
||||||
|
executionWorkspaceById,
|
||||||
|
projectWorkspaceById,
|
||||||
|
defaultProjectWorkspaceIdByProjectId,
|
||||||
|
})}
|
||||||
|
assigneeName={agentName(issue.assigneeAgentId)}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { PatchInstanceGeneralSettings } from "@paperclipai/shared";
|
||||||
import { SlidersHorizontal } from "lucide-react";
|
import { SlidersHorizontal } from "lucide-react";
|
||||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
@@ -51,6 +52,7 @@ export function InstanceGeneralSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
|
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
|
||||||
|
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
|
||||||
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
|
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,6 +108,36 @@ export function InstanceGeneralSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Enable app keyboard shortcuts, including inbox navigation and global shortcuts like creating issues or
|
||||||
|
toggling panels. This is off by default.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="toggle"
|
||||||
|
aria-label="Toggle keyboard shortcuts"
|
||||||
|
disabled={updateGeneralMutation.isPending}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
keyboardShortcuts ? "bg-green-600" : "bg-muted",
|
||||||
|
)}
|
||||||
|
onClick={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||||
|
keyboardShortcuts ? "translate-x-4.5" : "translate-x-0.5",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-card p-5">
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||||
import { Clock3, Copy, GitBranch, Loader2 } from "lucide-react";
|
import { Copy, FolderOpen, GitBranch, Loader2, Play, Square } from "lucide-react";
|
||||||
|
import { IssuesQuicklook } from "../components/IssuesQuicklook";
|
||||||
|
|
||||||
/* ── Top-level tab types ── */
|
/* ── Top-level tab types ── */
|
||||||
|
|
||||||
@@ -256,152 +257,144 @@ function ProjectWorkspacesContent({
|
|||||||
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
|
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
|
||||||
|
|
||||||
const renderSummaryRow = (summary: ReturnType<typeof buildProjectWorkspaceSummaries>[number]) => {
|
const renderSummaryRow = (summary: ReturnType<typeof buildProjectWorkspaceSummaries>[number]) => {
|
||||||
const visibleIssues = summary.issues.slice(0, 3);
|
const visibleIssues = summary.issues.slice(0, 5);
|
||||||
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
|
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
|
||||||
const workspaceHref =
|
const workspaceHref =
|
||||||
summary.kind === "project_workspace"
|
summary.kind === "project_workspace"
|
||||||
? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
|
? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
|
||||||
: `/execution-workspaces/${summary.workspaceId}`;
|
: `/execution-workspaces/${summary.workspaceId}`;
|
||||||
|
const hasRunningServices = summary.runningServiceCount > 0;
|
||||||
|
|
||||||
|
const truncatePath = (path: string) => {
|
||||||
|
const parts = path.split("/").filter(Boolean);
|
||||||
|
if (parts.length <= 3) return path;
|
||||||
|
return `…/${parts.slice(-2).join("/")}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={summary.key}
|
key={summary.key}
|
||||||
className="border-b border-border px-4 py-3 last:border-b-0"
|
className="border-b border-border px-4 py-3 last:border-b-0"
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-[minmax(0,18rem)_minmax(0,1fr)_auto] md:items-start">
|
{/* Header row: name + actions */}
|
||||||
<div className="min-w-0">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
to={workspaceHref}
|
to={workspaceHref}
|
||||||
className="block truncate text-sm font-medium hover:underline"
|
className="min-w-0 shrink truncate text-sm font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{summary.workspaceName}
|
{summary.workspaceName}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1">
|
{summary.serviceCount > 0 ? (
|
||||||
<GitBranch className="h-3.5 w-3.5" />
|
<span className={`inline-flex items-center gap-1 ${hasRunningServices ? "text-emerald-500" : ""}`}>
|
||||||
<span className="font-mono">{summary.branchName ?? "No branch info"}</span>
|
<span className={`inline-block h-1.5 w-1.5 rounded-full ${hasRunningServices ? "bg-emerald-500" : "bg-muted-foreground/40"}`} />
|
||||||
|
{summary.runningServiceCount}/{summary.serviceCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
|
|
||||||
{summary.runningServiceCount}/{summary.serviceCount} services running
|
|
||||||
</span>
|
|
||||||
{summary.executionWorkspaceStatus ? (
|
|
||||||
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
|
|
||||||
{summary.executionWorkspaceStatus}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{summary.primaryServiceUrl ? (
|
|
||||||
<a
|
|
||||||
href={summary.primaryServiceUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:underline"
|
|
||||||
>
|
|
||||||
{summary.primaryServiceUrl}
|
|
||||||
</a>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
{summary.executionWorkspaceStatus && summary.executionWorkspaceStatus !== "active" ? (
|
||||||
{summary.cwd ? (
|
<span className="text-[11px] text-muted-foreground">{summary.executionWorkspaceStatus}</span>
|
||||||
<div className="mt-2 flex min-w-0 items-start gap-2 text-xs text-muted-foreground">
|
|
||||||
<span className="min-w-0 truncate font-mono leading-tight" title={summary.cwd}>
|
|
||||||
{summary.cwd}
|
|
||||||
</span>
|
|
||||||
<CopyText text={summary.cwd} className="shrink-0" copiedLabel="Path copied">
|
|
||||||
<Copy className="h-3.5 w-3.5" />
|
|
||||||
</CopyText>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{timeAgo(summary.lastUpdatedAt)}</span>
|
||||||
Issues ({summary.issues.length})
|
{summary.hasRuntimeConfig ? (
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{visibleIssues.map((issue) => (
|
|
||||||
<Link
|
|
||||||
key={issue.id}
|
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
|
||||||
className="inline-flex max-w-full items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 text-left text-xs leading-none transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
||||||
</span>
|
|
||||||
<span className="truncate leading-tight">{issue.title}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
{hiddenIssueCount > 0 ? (
|
|
||||||
<span className="inline-flex items-center rounded-md border border-dashed border-border px-2.5 py-1.5 text-xs text-muted-foreground">
|
|
||||||
... and {hiddenIssueCount} more
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-start gap-2 md:items-end">
|
|
||||||
<Link
|
|
||||||
to={workspaceHref}
|
|
||||||
className="text-xs font-medium text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
{summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"}
|
|
||||||
</Link>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={
|
className="h-7 gap-1.5 px-2 text-xs"
|
||||||
controlWorkspaceRuntime.isPending
|
disabled={controlWorkspaceRuntime.isPending}
|
||||||
|| !summary.hasRuntimeConfig
|
|
||||||
|| runtimeActionKey !== null && runtimeActionKey !== `${summary.key}:start`
|
|
||||||
}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
controlWorkspaceRuntime.mutate({
|
controlWorkspaceRuntime.mutate({
|
||||||
key: summary.key,
|
key: summary.key,
|
||||||
kind: summary.kind,
|
kind: summary.kind,
|
||||||
workspaceId: summary.workspaceId,
|
workspaceId: summary.workspaceId,
|
||||||
action: "start",
|
action: hasRunningServices ? "stop" : "start",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{runtimeActionKey === `${summary.key}:start` ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
{runtimeActionKey === `${summary.key}:start` || runtimeActionKey === `${summary.key}:stop` ? (
|
||||||
Start
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : hasRunningServices ? (
|
||||||
|
<Square className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{hasRunningServices ? "Stop" : "Start"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
) : null}
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={controlWorkspaceRuntime.isPending || summary.serviceCount === 0}
|
|
||||||
onClick={() =>
|
|
||||||
controlWorkspaceRuntime.mutate({
|
|
||||||
key: summary.key,
|
|
||||||
kind: summary.kind,
|
|
||||||
workspaceId: summary.workspaceId,
|
|
||||||
action: "stop",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? (
|
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground"
|
||||||
onClick={() => setClosingWorkspace({
|
onClick={() => setClosingWorkspace({
|
||||||
id: summary.executionWorkspaceId!,
|
id: summary.executionWorkspaceId!,
|
||||||
name: summary.workspaceName,
|
name: summary.workspaceName,
|
||||||
status: summary.executionWorkspaceStatus!,
|
status: summary.executionWorkspaceStatus!,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{summary.executionWorkspaceStatus === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
{summary.executionWorkspaceStatus === "cleanup_failed" ? "Retry close" : "Close"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<Clock3 className="h-3.5 w-3.5" />
|
|
||||||
{timeAgo(summary.lastUpdatedAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata lines: branch, folder */}
|
||||||
|
<div className="mt-1.5 space-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{summary.branchName ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<GitBranch className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="font-mono">{summary.branchName}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{summary.cwd ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<FolderOpen className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate font-mono" title={summary.cwd}>
|
||||||
|
{truncatePath(summary.cwd)}
|
||||||
|
</span>
|
||||||
|
<CopyText text={summary.cwd} className="shrink-0" copiedLabel="Path copied">
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</CopyText>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{summary.primaryServiceUrl ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<a
|
||||||
|
href={summary.primaryServiceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-mono hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{summary.primaryServiceUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issues */}
|
||||||
|
{summary.issues.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-muted-foreground/70">Issues</span>
|
||||||
|
{visibleIssues.map((issue) => (
|
||||||
|
<IssuesQuicklook key={issue.id} issue={issue}>
|
||||||
|
<Link
|
||||||
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
|
className="font-mono hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
</IssuesQuicklook>
|
||||||
|
))}
|
||||||
|
{hiddenIssueCount > 0 ? (
|
||||||
|
<Link to={workspaceHref} className="hover:text-foreground hover:underline">
|
||||||
|
+{hiddenIssueCount} more
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -488,6 +481,7 @@ export function ProjectDetail() {
|
|||||||
const experimentalSettingsQuery = useQuery({
|
const experimentalSettingsQuery = useQuery({
|
||||||
queryKey: queryKeys.instance.experimentalSettings,
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
slots: pluginDetailSlots,
|
slots: pluginDetailSlots,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
|
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
@@ -31,6 +32,12 @@ import { PageSkeleton } from "../components/PageSkeleton";
|
|||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||||
|
import {
|
||||||
|
RoutineRunVariablesDialog,
|
||||||
|
routineRunNeedsConfiguration,
|
||||||
|
type RoutineRunDialogSubmitData,
|
||||||
|
} from "../components/RoutineRunVariablesDialog";
|
||||||
|
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
||||||
import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor";
|
import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor";
|
||||||
import { RunButton } from "../components/AgentActionButtons";
|
import { RunButton } from "../components/AgentActionButtons";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
@@ -48,7 +55,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import type { RoutineTrigger } from "@paperclipai/shared";
|
import type { RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
|
||||||
|
|
||||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||||
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
||||||
@@ -216,7 +223,7 @@ function TriggerEditor({
|
|||||||
onClick={() => onSave(trigger.id, buildRoutineTriggerPatch(trigger, draft, getLocalTimezone()))}
|
onClick={() => onSave(trigger.id, buildRoutineTriggerPatch(trigger, draft, getLocalTimezone()))}
|
||||||
>
|
>
|
||||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Save
|
Save trigger
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -247,13 +254,23 @@ export function RoutineDetail() {
|
|||||||
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
|
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const [runVariablesOpen, setRunVariablesOpen] = useState(false);
|
||||||
const [newTrigger, setNewTrigger] = useState({
|
const [newTrigger, setNewTrigger] = useState({
|
||||||
kind: "schedule",
|
kind: "schedule",
|
||||||
cronExpression: "0 10 * * *",
|
cronExpression: "0 10 * * *",
|
||||||
signingMode: "bearer",
|
signingMode: "bearer",
|
||||||
replayWindowSec: "300",
|
replayWindowSec: "300",
|
||||||
});
|
});
|
||||||
const [editDraft, setEditDraft] = useState({
|
const [editDraft, setEditDraft] = useState<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
projectId: string;
|
||||||
|
assigneeAgentId: string;
|
||||||
|
priority: string;
|
||||||
|
concurrencyPolicy: string;
|
||||||
|
catchUpPolicy: string;
|
||||||
|
variables: RoutineVariable[];
|
||||||
|
}>({
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
projectId: "",
|
projectId: "",
|
||||||
@@ -261,6 +278,7 @@ export function RoutineDetail() {
|
|||||||
priority: "medium",
|
priority: "medium",
|
||||||
concurrencyPolicy: "coalesce_if_active",
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
catchUpPolicy: "skip_missed",
|
catchUpPolicy: "skip_missed",
|
||||||
|
variables: [],
|
||||||
});
|
});
|
||||||
const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]);
|
const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]);
|
||||||
|
|
||||||
@@ -309,6 +327,11 @@ export function RoutineDetail() {
|
|||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
const routineDefaults = useMemo(
|
const routineDefaults = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -321,6 +344,7 @@ export function RoutineDetail() {
|
|||||||
priority: routine.priority,
|
priority: routine.priority,
|
||||||
concurrencyPolicy: routine.concurrencyPolicy,
|
concurrencyPolicy: routine.concurrencyPolicy,
|
||||||
catchUpPolicy: routine.catchUpPolicy,
|
catchUpPolicy: routine.catchUpPolicy,
|
||||||
|
variables: routine.variables,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
[routine],
|
[routine],
|
||||||
@@ -334,7 +358,8 @@ export function RoutineDetail() {
|
|||||||
editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId ||
|
editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId ||
|
||||||
editDraft.priority !== routineDefaults.priority ||
|
editDraft.priority !== routineDefaults.priority ||
|
||||||
editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy ||
|
editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy ||
|
||||||
editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy
|
editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy ||
|
||||||
|
JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)
|
||||||
);
|
);
|
||||||
}, [editDraft, routineDefaults]);
|
}, [editDraft, routineDefaults]);
|
||||||
|
|
||||||
@@ -409,9 +434,20 @@ export function RoutineDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const runRoutine = useMutation({
|
const runRoutine = useMutation({
|
||||||
mutationFn: () => routinesApi.run(routineId!),
|
mutationFn: (data?: RoutineRunDialogSubmitData) =>
|
||||||
|
routinesApi.run(routineId!, {
|
||||||
|
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
|
||||||
|
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
|
||||||
|
...(data?.executionWorkspacePreference !== undefined
|
||||||
|
? { executionWorkspacePreference: data.executionWorkspacePreference }
|
||||||
|
: {}),
|
||||||
|
...(data?.executionWorkspaceSettings !== undefined
|
||||||
|
? { executionWorkspaceSettings: data.executionWorkspaceSettings }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
pushToast({ title: "Routine run started", tone: "success" });
|
pushToast({ title: "Routine run started", tone: "success" });
|
||||||
|
setRunVariablesOpen(false);
|
||||||
setActiveTab("runs");
|
setActiveTab("runs");
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
||||||
@@ -476,6 +512,12 @@ export function RoutineDetail() {
|
|||||||
webhookUrl: result.secretMaterial.webhookUrl,
|
webhookUrl: result.secretMaterial.webhookUrl,
|
||||||
webhookSecret: result.secretMaterial.webhookSecret,
|
webhookSecret: result.secretMaterial.webhookSecret,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
pushToast({
|
||||||
|
title: "Trigger added",
|
||||||
|
body: "The routine schedule was saved.",
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
||||||
@@ -495,6 +537,11 @@ export function RoutineDetail() {
|
|||||||
const updateTrigger = useMutation({
|
const updateTrigger = useMutation({
|
||||||
mutationFn: ({ id, patch }: { id: string; patch: Record<string, unknown> }) => routinesApi.updateTrigger(id, patch),
|
mutationFn: ({ id, patch }: { id: string; patch: Record<string, unknown> }) => routinesApi.updateTrigger(id, patch),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
pushToast({
|
||||||
|
title: "Trigger saved",
|
||||||
|
body: "The routine cadence update was saved.",
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||||
@@ -513,6 +560,10 @@ export function RoutineDetail() {
|
|||||||
const deleteTrigger = useMutation({
|
const deleteTrigger = useMutation({
|
||||||
mutationFn: (id: string) => routinesApi.deleteTrigger(id),
|
mutationFn: (id: string) => routinesApi.deleteTrigger(id),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
pushToast({
|
||||||
|
title: "Trigger deleted",
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||||
@@ -600,6 +651,12 @@ export function RoutineDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const automationEnabled = routine.status === "active";
|
const automationEnabled = routine.status === "active";
|
||||||
|
const selectedProject = projects?.find((project) => project.id === routine.projectId) ?? null;
|
||||||
|
const needsRunConfiguration = routineRunNeedsConfiguration({
|
||||||
|
variables: routine.variables ?? [],
|
||||||
|
project: selectedProject,
|
||||||
|
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
|
||||||
|
});
|
||||||
const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived";
|
const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived";
|
||||||
const automationLabel = routine.status === "archived" ? "Archived" : automationEnabled ? "Active" : "Paused";
|
const automationLabel = routine.status === "archived" ? "Archived" : automationEnabled ? "Active" : "Paused";
|
||||||
const automationLabelClassName = routine.status === "archived"
|
const automationLabelClassName = routine.status === "archived"
|
||||||
@@ -643,7 +700,16 @@ export function RoutineDetail() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex shrink-0 items-center gap-3 pt-1">
|
<div className="flex shrink-0 items-center gap-3 pt-1">
|
||||||
<RunButton onClick={() => runRoutine.mutate()} disabled={runRoutine.isPending} />
|
<RunButton
|
||||||
|
onClick={() => {
|
||||||
|
if (needsRunConfiguration) {
|
||||||
|
setRunVariablesOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runRoutine.mutate({});
|
||||||
|
}}
|
||||||
|
disabled={runRoutine.isPending}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
@@ -797,6 +863,12 @@ export function RoutineDetail() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<RoutineVariablesHint />
|
||||||
|
<RoutineVariablesEditor
|
||||||
|
description={editDraft.description}
|
||||||
|
value={editDraft.variables}
|
||||||
|
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Advanced delivery settings */}
|
{/* Advanced delivery settings */}
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
@@ -1016,6 +1088,16 @@ export function RoutineDetail() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<RoutineRunVariablesDialog
|
||||||
|
open={runVariablesOpen}
|
||||||
|
onOpenChange={setRunVariablesOpen}
|
||||||
|
companyId={routine.companyId}
|
||||||
|
project={selectedProject}
|
||||||
|
variables={routine.variables ?? []}
|
||||||
|
isPending={runRoutine.isPending}
|
||||||
|
onSubmit={(data) => runRoutine.mutate(data)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useNavigate } from "@/lib/router";
|
import { useNavigate } from "@/lib/router";
|
||||||
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react";
|
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react";
|
||||||
import { routinesApi } from "../api/routines";
|
import { routinesApi } from "../api/routines";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
@@ -15,6 +16,12 @@ import { PageSkeleton } from "../components/PageSkeleton";
|
|||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||||
|
import {
|
||||||
|
RoutineRunVariablesDialog,
|
||||||
|
routineRunNeedsConfiguration,
|
||||||
|
type RoutineRunDialogSubmitData,
|
||||||
|
} from "../components/RoutineRunVariablesDialog";
|
||||||
|
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
@@ -33,6 +40,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
|
||||||
|
|
||||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||||
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
||||||
@@ -74,9 +82,19 @@ export function Routines() {
|
|||||||
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
|
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
|
||||||
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
|
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
|
||||||
|
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
|
||||||
const [composerOpen, setComposerOpen] = useState(false);
|
const [composerOpen, setComposerOpen] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const [draft, setDraft] = useState({
|
const [draft, setDraft] = useState<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
projectId: string;
|
||||||
|
assigneeAgentId: string;
|
||||||
|
priority: string;
|
||||||
|
concurrencyPolicy: string;
|
||||||
|
catchUpPolicy: string;
|
||||||
|
variables: RoutineVariable[];
|
||||||
|
}>({
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
projectId: "",
|
projectId: "",
|
||||||
@@ -84,6 +102,7 @@ export function Routines() {
|
|||||||
priority: "medium",
|
priority: "medium",
|
||||||
concurrencyPolicy: "coalesce_if_active",
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
catchUpPolicy: "skip_missed",
|
catchUpPolicy: "skip_missed",
|
||||||
|
variables: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,6 +124,11 @@ export function Routines() {
|
|||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
autoResizeTextarea(titleInputRef.current);
|
autoResizeTextarea(titleInputRef.current);
|
||||||
@@ -125,6 +149,7 @@ export function Routines() {
|
|||||||
priority: "medium",
|
priority: "medium",
|
||||||
concurrencyPolicy: "coalesce_if_active",
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
catchUpPolicy: "skip_missed",
|
catchUpPolicy: "skip_missed",
|
||||||
|
variables: [],
|
||||||
});
|
});
|
||||||
setComposerOpen(false);
|
setComposerOpen(false);
|
||||||
setAdvancedOpen(false);
|
setAdvancedOpen(false);
|
||||||
@@ -162,11 +187,21 @@ export function Routines() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const runRoutine = useMutation({
|
const runRoutine = useMutation({
|
||||||
mutationFn: (id: string) => routinesApi.run(id),
|
mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, {
|
||||||
onMutate: (id) => {
|
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
|
||||||
|
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
|
||||||
|
...(data?.executionWorkspacePreference !== undefined
|
||||||
|
? { executionWorkspacePreference: data.executionWorkspacePreference }
|
||||||
|
: {}),
|
||||||
|
...(data?.executionWorkspaceSettings !== undefined
|
||||||
|
? { executionWorkspaceSettings: data.executionWorkspaceSettings }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
onMutate: ({ id }) => {
|
||||||
setRunningRoutineId(id);
|
setRunningRoutineId(id);
|
||||||
},
|
},
|
||||||
onSuccess: async (_, id) => {
|
onSuccess: async (_, { id }) => {
|
||||||
|
setRunDialogRoutine(null);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }),
|
||||||
@@ -214,9 +249,24 @@ export function Routines() {
|
|||||||
() => new Map((projects ?? []).map((project) => [project.id, project])),
|
() => new Map((projects ?? []).map((project) => [project.id, project])),
|
||||||
[projects],
|
[projects],
|
||||||
);
|
);
|
||||||
|
const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
|
||||||
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
||||||
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
||||||
|
|
||||||
|
function handleRunNow(routine: RoutineListItem) {
|
||||||
|
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||||
|
const needsConfiguration = routineRunNeedsConfiguration({
|
||||||
|
variables: routine.variables ?? [],
|
||||||
|
project,
|
||||||
|
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
|
||||||
|
});
|
||||||
|
if (needsConfiguration) {
|
||||||
|
setRunDialogRoutine(routine);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runRoutine.mutate({ id: routine.id, data: {} });
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
||||||
}
|
}
|
||||||
@@ -414,6 +464,14 @@ export function Routines() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<RoutineVariablesHint />
|
||||||
|
<RoutineVariablesEditor
|
||||||
|
description={draft.description}
|
||||||
|
value={draft.variables}
|
||||||
|
onChange={(variables) => setDraft((current) => ({ ...current, variables }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 px-5 py-3">
|
<div className="border-t border-border/60 px-5 py-3">
|
||||||
@@ -623,7 +681,7 @@ export function Routines() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={runningRoutineId === routine.id || isArchived}
|
disabled={runningRoutineId === routine.id || isArchived}
|
||||||
onClick={() => runRoutine.mutate(routine.id)}
|
onClick={() => handleRunNow(routine)}
|
||||||
>
|
>
|
||||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -661,6 +719,21 @@ export function Routines() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RoutineRunVariablesDialog
|
||||||
|
open={runDialogRoutine !== null}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) setRunDialogRoutine(null);
|
||||||
|
}}
|
||||||
|
companyId={selectedCompanyId}
|
||||||
|
project={runDialogProject}
|
||||||
|
variables={runDialogRoutine?.variables ?? []}
|
||||||
|
isPending={runRoutine.isPending}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
if (!runDialogRoutine) return;
|
||||||
|
runRoutine.mutate({ id: runDialogRoutine.id, data });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user